dsh-plugin-coding-delegate 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +920 -2
- package/cordis.patch.example.yml +48 -0
- package/cordis.patch.yml +13 -0
- package/dist/attestation.d.ts +25 -0
- package/dist/attestation.js +44 -0
- package/dist/containment.d.ts +33 -0
- package/dist/containment.js +63 -0
- package/dist/context-quality.d.ts +79 -0
- package/dist/context-quality.js +181 -0
- package/dist/context.d.ts +39 -0
- package/dist/context.js +136 -0
- package/dist/contracts.d.ts +174 -0
- package/dist/contracts.js +415 -0
- package/dist/declaration-egress-hook.d.ts +45 -0
- package/dist/declaration-egress-hook.js +125 -0
- package/dist/declaration-egress.d.ts +37 -0
- package/dist/declaration-egress.js +169 -0
- package/dist/delegation.d.ts +339 -0
- package/dist/delegation.js +933 -0
- package/dist/emission.d.ts +70 -0
- package/dist/emission.js +310 -0
- package/dist/guard.d.ts +162 -0
- package/dist/guard.js +873 -0
- package/dist/host-events.d.ts +15 -0
- package/dist/host-events.js +27 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +1398 -0
- package/dist/local-classifier.d.ts +52 -0
- package/dist/local-classifier.js +159 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +90 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +83 -0
- package/dist/profiles.d.ts +35 -0
- package/dist/profiles.js +95 -0
- package/dist/retry-context.d.ts +52 -0
- package/dist/retry-context.js +97 -0
- package/dist/roles.d.ts +129 -0
- package/dist/roles.js +363 -0
- package/dist/routing.d.ts +35 -0
- package/dist/routing.js +88 -0
- package/dist/savings-tracker.d.ts +128 -0
- package/dist/savings-tracker.js +340 -0
- package/dist/session-events.d.ts +17 -0
- package/dist/session-events.js +31 -0
- package/dist/verification.d.ts +104 -0
- package/dist/verification.js +560 -0
- package/package.json +66 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runInProcessFallback = exports.resolveVerificationTimeoutMs = exports.redactVerificationOutput = exports.parseTestOutput = exports.evaluateVerificationPolicy = exports.describeFailures = exports.commandProgram = exports.DEFAULT_VERIFICATION_TIMEOUT_MS = exports.DEFAULT_VERIFICATION_POLICY = exports.sha256File = exports.saveDelegatedRegistry = exports.resolveDelegatedRegistryPath = exports.resolveContractFiles = exports.recordOperatorAttestation = exports.rememberDelegated = exports.pruneDelegatedRecords = exports.parseDelegatedRegistry = exports.mergeDelegatedRecords = exports.loadDelegatedRegistry = exports.contractViolations = exports.contractFileHashes = exports.resolveContextFiles = exports.DEFAULT_CONTEXT_MAX_BYTES = exports.roleFromLineage = exports.roleForAgent = exports.resolveLeadProviders = exports.resolveAgentRole = exports.resetAgentRoles = exports.rememberAgentRole = exports.evaluateSourceEgress = exports.detectSourceEgress = exports.describeSourceRead = exports.applyArchitectConfig = exports.applyAgentRole = exports.agentLineageRole = exports.DEFAULT_SOURCE_EGRESS_MIN_LINES = exports.AGENT_ROLE_LIMIT = exports.hasCommandWriteSignal = exports.hasCommandDeleteSignal = exports.evaluateDelegatedReadPolicy = exports.evaluateCodeWriteGuard = exports.DELETE_PRIMITIVES = exports.extractAndEmitFiles = exports.evaluateUnitScope = exports.evaluateEmissionPath = exports.isPathWithin = exports.trace = exports.resolveDataDir = exports.SavingsTracker = exports.PROFILES = void 0;
|
|
37
|
+
exports.LocalRouter = exports.name = exports.using = exports.inject = exports.retryContextRequests = exports.parseFailureLocations = exports.RETRY_CONTEXT_WINDOW_LINES = exports.resolveVerificationRepeats = exports.resolveDelegateStatus = exports.resolveChatCompletionsUrl = exports.parseSearchReplaceBlocks = exports.extractPromptText = exports.estimateTokenCount = exports.delegateWorker = exports.applySearchReplaceBlocks = exports.MIN_SEARCH_CHARS = exports.DEFAULT_LOCAL_ENDPOINT = exports.DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER = exports.DELEGATE_WORKER_SCHEMA = exports.DELEGATE_WORKER_OPENAI_SCHEMA = exports.foldContextQuality = exports.describeContextQuality = exports.EMPTY_CONTEXT_QUALITY = exports.runSandboxVerification = void 0;
|
|
38
|
+
exports.scanDLP = scanDLP;
|
|
39
|
+
exports.requestApprovalForWrite = requestApprovalForWrite;
|
|
40
|
+
exports.resolveVerificationPolicy = resolveVerificationPolicy;
|
|
41
|
+
exports.requestApprovalForVerification = requestApprovalForVerification;
|
|
42
|
+
exports.requestApprovalForWorkspace = requestApprovalForWorkspace;
|
|
43
|
+
exports.apply = apply;
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const savings_tracker_1 = require("./savings-tracker");
|
|
46
|
+
Object.defineProperty(exports, "SavingsTracker", { enumerable: true, get: function () { return savings_tracker_1.SavingsTracker; } });
|
|
47
|
+
const profiles_1 = require("./profiles");
|
|
48
|
+
Object.defineProperty(exports, "PROFILES", { enumerable: true, get: function () { return profiles_1.PROFILES; } });
|
|
49
|
+
const local_classifier_1 = require("./local-classifier");
|
|
50
|
+
const logging_1 = require("./logging");
|
|
51
|
+
const paths_1 = require("./paths");
|
|
52
|
+
const declaration_egress_hook_1 = require("./declaration-egress-hook");
|
|
53
|
+
const containment_1 = require("./containment");
|
|
54
|
+
const routing_1 = require("./routing");
|
|
55
|
+
const attestation_1 = require("./attestation");
|
|
56
|
+
const emission_1 = require("./emission");
|
|
57
|
+
const verification_1 = require("./verification");
|
|
58
|
+
const context_1 = require("./context");
|
|
59
|
+
const guard_1 = require("./guard");
|
|
60
|
+
const roles_1 = require("./roles");
|
|
61
|
+
const delegation_1 = require("./delegation");
|
|
62
|
+
const contracts_1 = require("./contracts");
|
|
63
|
+
const context_quality_1 = require("./context-quality");
|
|
64
|
+
const session_events_1 = require("./session-events");
|
|
65
|
+
const host_events_1 = require("./host-events");
|
|
66
|
+
var logging_2 = require("./logging");
|
|
67
|
+
Object.defineProperty(exports, "resolveDataDir", { enumerable: true, get: function () { return logging_2.resolveDataDir; } });
|
|
68
|
+
Object.defineProperty(exports, "trace", { enumerable: true, get: function () { return logging_2.trace; } });
|
|
69
|
+
var paths_2 = require("./paths");
|
|
70
|
+
Object.defineProperty(exports, "isPathWithin", { enumerable: true, get: function () { return paths_2.isPathWithin; } });
|
|
71
|
+
var emission_2 = require("./emission");
|
|
72
|
+
Object.defineProperty(exports, "evaluateEmissionPath", { enumerable: true, get: function () { return emission_2.evaluateEmissionPath; } });
|
|
73
|
+
Object.defineProperty(exports, "evaluateUnitScope", { enumerable: true, get: function () { return emission_2.evaluateUnitScope; } });
|
|
74
|
+
Object.defineProperty(exports, "extractAndEmitFiles", { enumerable: true, get: function () { return emission_2.extractAndEmitFiles; } });
|
|
75
|
+
var guard_2 = require("./guard");
|
|
76
|
+
Object.defineProperty(exports, "DELETE_PRIMITIVES", { enumerable: true, get: function () { return guard_2.DELETE_PRIMITIVES; } });
|
|
77
|
+
Object.defineProperty(exports, "evaluateCodeWriteGuard", { enumerable: true, get: function () { return guard_2.evaluateCodeWriteGuard; } });
|
|
78
|
+
Object.defineProperty(exports, "evaluateDelegatedReadPolicy", { enumerable: true, get: function () { return guard_2.evaluateDelegatedReadPolicy; } });
|
|
79
|
+
Object.defineProperty(exports, "hasCommandDeleteSignal", { enumerable: true, get: function () { return guard_2.hasCommandDeleteSignal; } });
|
|
80
|
+
Object.defineProperty(exports, "hasCommandWriteSignal", { enumerable: true, get: function () { return guard_2.hasCommandWriteSignal; } });
|
|
81
|
+
var roles_2 = require("./roles");
|
|
82
|
+
Object.defineProperty(exports, "AGENT_ROLE_LIMIT", { enumerable: true, get: function () { return roles_2.AGENT_ROLE_LIMIT; } });
|
|
83
|
+
Object.defineProperty(exports, "DEFAULT_SOURCE_EGRESS_MIN_LINES", { enumerable: true, get: function () { return roles_2.DEFAULT_SOURCE_EGRESS_MIN_LINES; } });
|
|
84
|
+
Object.defineProperty(exports, "agentLineageRole", { enumerable: true, get: function () { return roles_2.agentLineageRole; } });
|
|
85
|
+
Object.defineProperty(exports, "applyAgentRole", { enumerable: true, get: function () { return roles_2.applyAgentRole; } });
|
|
86
|
+
Object.defineProperty(exports, "applyArchitectConfig", { enumerable: true, get: function () { return roles_2.applyArchitectConfig; } });
|
|
87
|
+
Object.defineProperty(exports, "describeSourceRead", { enumerable: true, get: function () { return roles_2.describeSourceRead; } });
|
|
88
|
+
Object.defineProperty(exports, "detectSourceEgress", { enumerable: true, get: function () { return roles_2.detectSourceEgress; } });
|
|
89
|
+
Object.defineProperty(exports, "evaluateSourceEgress", { enumerable: true, get: function () { return roles_2.evaluateSourceEgress; } });
|
|
90
|
+
Object.defineProperty(exports, "rememberAgentRole", { enumerable: true, get: function () { return roles_2.rememberAgentRole; } });
|
|
91
|
+
Object.defineProperty(exports, "resetAgentRoles", { enumerable: true, get: function () { return roles_2.resetAgentRoles; } });
|
|
92
|
+
Object.defineProperty(exports, "resolveAgentRole", { enumerable: true, get: function () { return roles_2.resolveAgentRole; } });
|
|
93
|
+
Object.defineProperty(exports, "resolveLeadProviders", { enumerable: true, get: function () { return roles_2.resolveLeadProviders; } });
|
|
94
|
+
Object.defineProperty(exports, "roleForAgent", { enumerable: true, get: function () { return roles_2.roleForAgent; } });
|
|
95
|
+
Object.defineProperty(exports, "roleFromLineage", { enumerable: true, get: function () { return roles_2.roleFromLineage; } });
|
|
96
|
+
var context_2 = require("./context");
|
|
97
|
+
Object.defineProperty(exports, "DEFAULT_CONTEXT_MAX_BYTES", { enumerable: true, get: function () { return context_2.DEFAULT_CONTEXT_MAX_BYTES; } });
|
|
98
|
+
Object.defineProperty(exports, "resolveContextFiles", { enumerable: true, get: function () { return context_2.resolveContextFiles; } });
|
|
99
|
+
var contracts_2 = require("./contracts");
|
|
100
|
+
Object.defineProperty(exports, "contractFileHashes", { enumerable: true, get: function () { return contracts_2.contractFileHashes; } });
|
|
101
|
+
Object.defineProperty(exports, "contractViolations", { enumerable: true, get: function () { return contracts_2.contractViolations; } });
|
|
102
|
+
Object.defineProperty(exports, "loadDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.loadDelegatedRegistry; } });
|
|
103
|
+
Object.defineProperty(exports, "mergeDelegatedRecords", { enumerable: true, get: function () { return contracts_2.mergeDelegatedRecords; } });
|
|
104
|
+
Object.defineProperty(exports, "parseDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.parseDelegatedRegistry; } });
|
|
105
|
+
Object.defineProperty(exports, "pruneDelegatedRecords", { enumerable: true, get: function () { return contracts_2.pruneDelegatedRecords; } });
|
|
106
|
+
Object.defineProperty(exports, "rememberDelegated", { enumerable: true, get: function () { return contracts_2.rememberDelegated; } });
|
|
107
|
+
Object.defineProperty(exports, "recordOperatorAttestation", { enumerable: true, get: function () { return contracts_2.recordOperatorAttestation; } });
|
|
108
|
+
Object.defineProperty(exports, "resolveContractFiles", { enumerable: true, get: function () { return contracts_2.resolveContractFiles; } });
|
|
109
|
+
Object.defineProperty(exports, "resolveDelegatedRegistryPath", { enumerable: true, get: function () { return contracts_2.resolveDelegatedRegistryPath; } });
|
|
110
|
+
Object.defineProperty(exports, "saveDelegatedRegistry", { enumerable: true, get: function () { return contracts_2.saveDelegatedRegistry; } });
|
|
111
|
+
Object.defineProperty(exports, "sha256File", { enumerable: true, get: function () { return contracts_2.sha256File; } });
|
|
112
|
+
var verification_2 = require("./verification");
|
|
113
|
+
Object.defineProperty(exports, "DEFAULT_VERIFICATION_POLICY", { enumerable: true, get: function () { return verification_2.DEFAULT_VERIFICATION_POLICY; } });
|
|
114
|
+
Object.defineProperty(exports, "DEFAULT_VERIFICATION_TIMEOUT_MS", { enumerable: true, get: function () { return verification_2.DEFAULT_VERIFICATION_TIMEOUT_MS; } });
|
|
115
|
+
Object.defineProperty(exports, "commandProgram", { enumerable: true, get: function () { return verification_2.commandProgram; } });
|
|
116
|
+
Object.defineProperty(exports, "describeFailures", { enumerable: true, get: function () { return verification_2.describeFailures; } });
|
|
117
|
+
Object.defineProperty(exports, "evaluateVerificationPolicy", { enumerable: true, get: function () { return verification_2.evaluateVerificationPolicy; } });
|
|
118
|
+
Object.defineProperty(exports, "parseTestOutput", { enumerable: true, get: function () { return verification_2.parseTestOutput; } });
|
|
119
|
+
Object.defineProperty(exports, "redactVerificationOutput", { enumerable: true, get: function () { return verification_2.redactVerificationOutput; } });
|
|
120
|
+
Object.defineProperty(exports, "resolveVerificationTimeoutMs", { enumerable: true, get: function () { return verification_2.resolveVerificationTimeoutMs; } });
|
|
121
|
+
Object.defineProperty(exports, "runInProcessFallback", { enumerable: true, get: function () { return verification_2.runInProcessFallback; } });
|
|
122
|
+
Object.defineProperty(exports, "runSandboxVerification", { enumerable: true, get: function () { return verification_2.runSandboxVerification; } });
|
|
123
|
+
var context_quality_2 = require("./context-quality");
|
|
124
|
+
Object.defineProperty(exports, "EMPTY_CONTEXT_QUALITY", { enumerable: true, get: function () { return context_quality_2.EMPTY_CONTEXT_QUALITY; } });
|
|
125
|
+
Object.defineProperty(exports, "describeContextQuality", { enumerable: true, get: function () { return context_quality_2.describeContextQuality; } });
|
|
126
|
+
Object.defineProperty(exports, "foldContextQuality", { enumerable: true, get: function () { return context_quality_2.foldContextQuality; } });
|
|
127
|
+
var delegation_2 = require("./delegation");
|
|
128
|
+
Object.defineProperty(exports, "DELEGATE_WORKER_OPENAI_SCHEMA", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_OPENAI_SCHEMA; } });
|
|
129
|
+
Object.defineProperty(exports, "DELEGATE_WORKER_SCHEMA", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_SCHEMA; } });
|
|
130
|
+
Object.defineProperty(exports, "DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER", { enumerable: true, get: function () { return delegation_2.DELEGATE_WORKER_SCHEMA_COVERS_EVERY_PARAMETER; } });
|
|
131
|
+
Object.defineProperty(exports, "DEFAULT_LOCAL_ENDPOINT", { enumerable: true, get: function () { return delegation_2.DEFAULT_LOCAL_ENDPOINT; } });
|
|
132
|
+
Object.defineProperty(exports, "MIN_SEARCH_CHARS", { enumerable: true, get: function () { return delegation_2.MIN_SEARCH_CHARS; } });
|
|
133
|
+
Object.defineProperty(exports, "applySearchReplaceBlocks", { enumerable: true, get: function () { return delegation_2.applySearchReplaceBlocks; } });
|
|
134
|
+
Object.defineProperty(exports, "delegateWorker", { enumerable: true, get: function () { return delegation_2.delegateWorker; } });
|
|
135
|
+
Object.defineProperty(exports, "estimateTokenCount", { enumerable: true, get: function () { return delegation_2.estimateTokenCount; } });
|
|
136
|
+
Object.defineProperty(exports, "extractPromptText", { enumerable: true, get: function () { return delegation_2.extractPromptText; } });
|
|
137
|
+
Object.defineProperty(exports, "parseSearchReplaceBlocks", { enumerable: true, get: function () { return delegation_2.parseSearchReplaceBlocks; } });
|
|
138
|
+
Object.defineProperty(exports, "resolveChatCompletionsUrl", { enumerable: true, get: function () { return delegation_2.resolveChatCompletionsUrl; } });
|
|
139
|
+
Object.defineProperty(exports, "resolveDelegateStatus", { enumerable: true, get: function () { return delegation_2.resolveDelegateStatus; } });
|
|
140
|
+
Object.defineProperty(exports, "resolveVerificationRepeats", { enumerable: true, get: function () { return delegation_2.resolveVerificationRepeats; } });
|
|
141
|
+
var retry_context_1 = require("./retry-context");
|
|
142
|
+
Object.defineProperty(exports, "RETRY_CONTEXT_WINDOW_LINES", { enumerable: true, get: function () { return retry_context_1.RETRY_CONTEXT_WINDOW_LINES; } });
|
|
143
|
+
Object.defineProperty(exports, "parseFailureLocations", { enumerable: true, get: function () { return retry_context_1.parseFailureLocations; } });
|
|
144
|
+
Object.defineProperty(exports, "retryContextRequests", { enumerable: true, get: function () { return retry_context_1.retryContextRequests; } });
|
|
145
|
+
exports.inject = ['tools'];
|
|
146
|
+
exports.using = ['tools'];
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the Session Workspace root WITHOUT declaring a hard inject dependency.
|
|
149
|
+
*
|
|
150
|
+
* ctx.workspace does not exist in Cordis (reading it throws), and dsh-workspace
|
|
151
|
+
* exposes a REGISTRY (ctx.workspaceRegistry), not a per-Session cwd. So probe the
|
|
152
|
+
* known shapes in order and report WHERE the answer came from: silently falling
|
|
153
|
+
* back to process.cwd() writes files into the server's own directory instead of
|
|
154
|
+
* the Session workspace, which is far worse than failing loudly.
|
|
155
|
+
*/
|
|
156
|
+
function resolveWorkspaceDir(ctx) {
|
|
157
|
+
for (const key of ['DSH_WORKSPACE_ROOT', 'DSH_WORKSPACE', 'WORKSPACE_ROOT']) {
|
|
158
|
+
const candidate = process.env[key];
|
|
159
|
+
if (typeof candidate === 'string' && candidate && fs.existsSync(candidate)) {
|
|
160
|
+
return { dir: candidate, source: `env:${key}` };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
for (const serviceName of ['workspace', 'workspaceRegistry', 'workspaceFiles', 'session']) {
|
|
165
|
+
const service = typeof ctx?.get === 'function' ? ctx.get(serviceName) : undefined;
|
|
166
|
+
if (!service)
|
|
167
|
+
continue;
|
|
168
|
+
for (const prop of ['dir', 'cwd', 'root', 'path', 'workspaceDir']) {
|
|
169
|
+
const value = service[prop];
|
|
170
|
+
if (typeof value === 'string' && value && fs.existsSync(value)) {
|
|
171
|
+
return { dir: value, source: `ctx.get('${serviceName}').${prop}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// service unavailable in this context - fall through to the cwd fallback
|
|
178
|
+
}
|
|
179
|
+
return { dir: process.cwd(), source: 'process.cwd() FALLBACK (not a Session workspace)' };
|
|
180
|
+
}
|
|
181
|
+
exports.name = 'dsh-plugin-coding-delegate';
|
|
182
|
+
// resolveDataDir and trace moved to ./logging.ts and are imported above. The re-export beside the other
|
|
183
|
+
// module re-exports keeps `resolveDataDir` on the public surface, where callers already depend on it.
|
|
184
|
+
// The delegate_worker schemas moved to ./delegation.ts and are re-exported below.
|
|
185
|
+
function scanDLP(text, options = {}) {
|
|
186
|
+
if (!text)
|
|
187
|
+
return { hasSensitiveData: false, violations: [], highConfidence: false };
|
|
188
|
+
const violations = [];
|
|
189
|
+
// Shared with the classifier: an enforcing gate must not use a weaker rule set
|
|
190
|
+
// than the classifier that reports alongside it (it previously did, which let
|
|
191
|
+
// `password: "..."` through to the cloud).
|
|
192
|
+
for (const { name, pattern } of local_classifier_1.SECRET_PATTERN_RULES) {
|
|
193
|
+
if (pattern.test(text)) {
|
|
194
|
+
violations.push(name);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// A recognised shape or keyword-assigned value is high confidence.
|
|
198
|
+
const highConfidence = violations.length > 0;
|
|
199
|
+
// The entropy backstop. Deliberately medium confidence: a high-entropy token may
|
|
200
|
+
// equally be a digest or a base64 payload, so it must not hard-block a session.
|
|
201
|
+
if (options.entropyCheck !== false) {
|
|
202
|
+
const tokens = (0, local_classifier_1.findHighEntropyTokens)(text, {
|
|
203
|
+
minBitsPerChar: options.entropyMinBitsPerChar,
|
|
204
|
+
minLength: options.entropyMinLength,
|
|
205
|
+
});
|
|
206
|
+
if (tokens.length > 0)
|
|
207
|
+
violations.push('High-entropy string');
|
|
208
|
+
}
|
|
209
|
+
return { hasSensitiveData: violations.length > 0, violations, highConfidence };
|
|
210
|
+
}
|
|
211
|
+
class LocalRouter {
|
|
212
|
+
config;
|
|
213
|
+
constructor(config = {}) {
|
|
214
|
+
const contextThreshold = config.contextThreshold ??
|
|
215
|
+
config.contextTokenThreshold ??
|
|
216
|
+
parseInt(process.env.CONTEXT_TOKEN_THRESHOLD || '30000', 10);
|
|
217
|
+
this.config = {
|
|
218
|
+
localProvider: config.localProvider || profiles_1.PROFILES.WORKER.provider,
|
|
219
|
+
cloudProvider: config.cloudProvider || profiles_1.PROFILES.ARCHITECT.provider,
|
|
220
|
+
localModel: config.localModel || profiles_1.PROFILES.WORKER.model,
|
|
221
|
+
cloudModel: config.cloudModel || profiles_1.PROFILES.ARCHITECT.model,
|
|
222
|
+
contextThreshold,
|
|
223
|
+
timeoutMs: config.timeoutMs || 2000,
|
|
224
|
+
enforceDLP: config.enforceDLP ?? true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
getConfig() {
|
|
228
|
+
return this.config;
|
|
229
|
+
}
|
|
230
|
+
async predictRoute(promptText) {
|
|
231
|
+
const dlpResult = scanDLP(promptText);
|
|
232
|
+
if (dlpResult.hasSensitiveData) {
|
|
233
|
+
return {
|
|
234
|
+
provider: this.config.localProvider,
|
|
235
|
+
model: this.config.localModel,
|
|
236
|
+
route: 'WORKER_LOCAL',
|
|
237
|
+
gate: 'Gate 1 (Local Classifier - DLP Firewall)',
|
|
238
|
+
rationale: `Sensitive credentials detected by DLP firewall (${dlpResult.violations.join(', ')}). Routing payload locally to protect privacy.`,
|
|
239
|
+
scores: { is_private: 0.99, complexity: 1, target: 'LOCAL_5090' },
|
|
240
|
+
latencyMs: 0,
|
|
241
|
+
dlpViolations: dlpResult.violations,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const tokens = (0, delegation_1.estimateTokenCount)(promptText);
|
|
245
|
+
if (tokens > this.config.contextThreshold) {
|
|
246
|
+
return {
|
|
247
|
+
provider: this.config.cloudProvider,
|
|
248
|
+
model: this.config.cloudModel,
|
|
249
|
+
route: 'ARCHITECT_CLOUD',
|
|
250
|
+
gate: 'Gate 0 (Guard - Token Threshold)',
|
|
251
|
+
rationale: `Prompt token count (${tokens}) exceeds local context threshold (${this.config.contextThreshold}). Routing directly to Cloud.`,
|
|
252
|
+
scores: { is_private: 0, complexity: 5, target: 'CLOUD_DEEPSEEK' },
|
|
253
|
+
latencyMs: 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// Routing is decided in-process. There is no decision daemon, no HTTP hop and
|
|
257
|
+
// no timeout to pay on every routing call, and nothing to fail over to: the
|
|
258
|
+
// classifier always returns a decision.
|
|
259
|
+
const decision = (0, local_classifier_1.classifyLocally)(promptText);
|
|
260
|
+
const isCloud = decision.route === 'cloud';
|
|
261
|
+
return {
|
|
262
|
+
provider: isCloud ? this.config.cloudProvider : this.config.localProvider,
|
|
263
|
+
model: isCloud ? this.config.cloudModel : this.config.localModel,
|
|
264
|
+
route: isCloud ? 'ARCHITECT_CLOUD' : 'WORKER_LOCAL',
|
|
265
|
+
gate: decision.gate,
|
|
266
|
+
rationale: decision.rationale,
|
|
267
|
+
scores: decision.scores,
|
|
268
|
+
latencyMs: decision.latencyMs,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async handleBeforeRequest(session) {
|
|
272
|
+
if (!session)
|
|
273
|
+
return session;
|
|
274
|
+
const fullText = (0, delegation_1.extractPromptText)(session);
|
|
275
|
+
if (!fullText || fullText.trim().length === 0) {
|
|
276
|
+
return {
|
|
277
|
+
...session,
|
|
278
|
+
provider: this.config.cloudProvider,
|
|
279
|
+
model: this.config.cloudModel,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const decision = await this.predictRoute(fullText);
|
|
283
|
+
const routerMeta = {
|
|
284
|
+
provider: decision.provider,
|
|
285
|
+
model: decision.model,
|
|
286
|
+
route: decision.route,
|
|
287
|
+
gate: decision.gate,
|
|
288
|
+
rationale: decision.rationale,
|
|
289
|
+
scores: decision.scores,
|
|
290
|
+
latencyMs: decision.latencyMs,
|
|
291
|
+
dlpViolations: decision.dlpViolations,
|
|
292
|
+
tier: decision.route === 'ARCHITECT_CLOUD' ? 'Cloud Tier (Cloud Architect)' : 'Local Tier (Local Worker)',
|
|
293
|
+
estimatedTokens: (0, delegation_1.estimateTokenCount)(fullText),
|
|
294
|
+
};
|
|
295
|
+
(0, logging_1.trace)('ROUTER_DECISION', {
|
|
296
|
+
prompt: fullText.slice(0, 100).replace(/\n/g, ' '),
|
|
297
|
+
tokens: (0, delegation_1.estimateTokenCount)(fullText),
|
|
298
|
+
gate: decision.gate,
|
|
299
|
+
selectedProvider: decision.provider,
|
|
300
|
+
selectedModel: decision.model,
|
|
301
|
+
rationale: decision.rationale,
|
|
302
|
+
});
|
|
303
|
+
const isLocal = decision.provider === this.config.localProvider;
|
|
304
|
+
if (Object.isExtensible(session)) {
|
|
305
|
+
try {
|
|
306
|
+
session.provider = decision.provider;
|
|
307
|
+
session.model = decision.model;
|
|
308
|
+
session.apiKey = isLocal ? 'KEY' : undefined;
|
|
309
|
+
if (isLocal) {
|
|
310
|
+
delete session.reasoningEffort;
|
|
311
|
+
}
|
|
312
|
+
if (!session.options)
|
|
313
|
+
session.options = {};
|
|
314
|
+
if (Object.isExtensible(session.options)) {
|
|
315
|
+
session.options.provider = decision.provider;
|
|
316
|
+
session.options.model = decision.model;
|
|
317
|
+
if (isLocal) {
|
|
318
|
+
session.options.apiKey = 'KEY';
|
|
319
|
+
delete session.options.reasoningEffort;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!session.metadata)
|
|
323
|
+
session.metadata = {};
|
|
324
|
+
if (Object.isExtensible(session.metadata)) {
|
|
325
|
+
session.metadata.router = routerMeta;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) { }
|
|
329
|
+
}
|
|
330
|
+
const updatedOptions = {
|
|
331
|
+
...(session.options || {}),
|
|
332
|
+
provider: decision.provider,
|
|
333
|
+
model: decision.model,
|
|
334
|
+
};
|
|
335
|
+
if (isLocal) {
|
|
336
|
+
updatedOptions.apiKey = 'KEY';
|
|
337
|
+
delete updatedOptions.reasoningEffort;
|
|
338
|
+
}
|
|
339
|
+
const updated = {
|
|
340
|
+
...session,
|
|
341
|
+
provider: decision.provider,
|
|
342
|
+
model: decision.model,
|
|
343
|
+
options: updatedOptions,
|
|
344
|
+
metadata: {
|
|
345
|
+
...(session.metadata || {}),
|
|
346
|
+
router: routerMeta,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
if (isLocal) {
|
|
350
|
+
updated.apiKey = 'KEY';
|
|
351
|
+
delete updated.reasoningEffort;
|
|
352
|
+
}
|
|
353
|
+
return updated;
|
|
354
|
+
}
|
|
355
|
+
async handleError(session, error) {
|
|
356
|
+
if (!session)
|
|
357
|
+
return session;
|
|
358
|
+
const currentProvider = session.provider || session.options?.provider || this.config.localProvider;
|
|
359
|
+
if (currentProvider === this.config.localProvider || session.metadata?.router?.route === 'WORKER_LOCAL' || session.metadata?.router?.route === 'local') {
|
|
360
|
+
const errorMessage = error?.message || String(error);
|
|
361
|
+
const prevMetadata = session.metadata?.router;
|
|
362
|
+
const routerMeta = {
|
|
363
|
+
...prevMetadata,
|
|
364
|
+
failover: true,
|
|
365
|
+
previousProvider: currentProvider,
|
|
366
|
+
provider: this.config.cloudProvider,
|
|
367
|
+
model: this.config.cloudModel,
|
|
368
|
+
route: 'cloud-failover',
|
|
369
|
+
gate: 'Gate 2 (Guard - Automatic Cloud Failover)',
|
|
370
|
+
rationale: `Local LM Studio provider failure caught (${errorMessage}). Transparently re-dispatching turn to DeepSeek Cloud.`,
|
|
371
|
+
tier: 'Cloud Tier (DeepSeek Cloud Fallback)',
|
|
372
|
+
estimatedTokens: prevMetadata?.estimatedTokens || 0,
|
|
373
|
+
};
|
|
374
|
+
(0, logging_1.trace)('ROUTER_FAILOVER', {
|
|
375
|
+
errorMessage,
|
|
376
|
+
cloudProvider: this.config.cloudProvider,
|
|
377
|
+
cloudModel: this.config.cloudModel,
|
|
378
|
+
});
|
|
379
|
+
if (Object.isExtensible(session)) {
|
|
380
|
+
try {
|
|
381
|
+
session.provider = this.config.cloudProvider;
|
|
382
|
+
session.model = this.config.cloudModel;
|
|
383
|
+
if (session.options && Object.isExtensible(session.options)) {
|
|
384
|
+
session.options.provider = this.config.cloudProvider;
|
|
385
|
+
session.options.model = this.config.cloudModel;
|
|
386
|
+
}
|
|
387
|
+
if (session.metadata && Object.isExtensible(session.metadata)) {
|
|
388
|
+
session.metadata.router = routerMeta;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (err) { }
|
|
392
|
+
}
|
|
393
|
+
const updated = {
|
|
394
|
+
...session,
|
|
395
|
+
provider: this.config.cloudProvider,
|
|
396
|
+
model: this.config.cloudModel,
|
|
397
|
+
options: {
|
|
398
|
+
...(session.options || {}),
|
|
399
|
+
provider: this.config.cloudProvider,
|
|
400
|
+
model: this.config.cloudModel,
|
|
401
|
+
},
|
|
402
|
+
metadata: {
|
|
403
|
+
...(session.metadata || {}),
|
|
404
|
+
router: routerMeta,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
if (typeof session.redispatch === 'function') {
|
|
408
|
+
await session.redispatch();
|
|
409
|
+
}
|
|
410
|
+
else if (typeof session.retry === 'function') {
|
|
411
|
+
await session.retry();
|
|
412
|
+
}
|
|
413
|
+
return updated;
|
|
414
|
+
}
|
|
415
|
+
return session;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
exports.LocalRouter = LocalRouter;
|
|
419
|
+
/**
|
|
420
|
+
* The prompt stashed at `agent/pre-step` for the request hook, keyed by session AND turn.
|
|
421
|
+
*
|
|
422
|
+
* The key carries the session because turn numbers are per session: every delegated subagent starts
|
|
423
|
+
* at turn 1, so keying on the turn alone let two sessions sitting on the same turn hand each other
|
|
424
|
+
* their text. The gate scans `corpus.length > prompt.length ? corpus : prompt`, so a borrowed prompt
|
|
425
|
+
* longer than this session's own corpus replaced it and this session's text was never scanned at all
|
|
426
|
+
* -- a credential went out unscanned, measured in tests/oracles/dlp-session-scope.test.cjs.
|
|
427
|
+
*/
|
|
428
|
+
const pendingTurnPrompts = new Map();
|
|
429
|
+
/** One stashed prompt per turn in flight; a turn whose request never runs would otherwise leak one. */
|
|
430
|
+
const PENDING_TURN_PROMPTS_MAX = 256;
|
|
431
|
+
/** Session ids and turn numbers cannot contain NUL, so the key is injective by construction. */
|
|
432
|
+
const TURN_PROMPT_SEPARATOR = '\u0000';
|
|
433
|
+
/**
|
|
434
|
+
* Every user-message text this plugin has seen, per session.
|
|
435
|
+
*
|
|
436
|
+
* The DLP gate can only scan what the host hands it: `agent/pre-step` receives the
|
|
437
|
+
* messages claimed for the current turn, and `agent/request` receives no message content
|
|
438
|
+
* at all because the outbound conversation is assembled from the session surface after
|
|
439
|
+
* that waterfall. Scanning only the newest message therefore let a credential introduced
|
|
440
|
+
* in an earlier turn be re-sent on every later request without tripping the gate.
|
|
441
|
+
* Accumulating makes the gate sticky instead.
|
|
442
|
+
*/
|
|
443
|
+
const sessionPromptCorpus = new Map();
|
|
444
|
+
const SESSION_CORPUS_MAX_CHARS = 200_000;
|
|
445
|
+
const GLOBAL_CORPUS_KEY = '__global__';
|
|
446
|
+
function corpusKeyFor(payload) {
|
|
447
|
+
const id = payload?.agent?.id ?? payload?.agent?.session?.id ?? payload?.session?.id;
|
|
448
|
+
return typeof id === 'string' && id.length > 0 ? id : GLOBAL_CORPUS_KEY;
|
|
449
|
+
}
|
|
450
|
+
/** The stash key for one session's turn. */
|
|
451
|
+
function turnPromptKey(payload, turn) {
|
|
452
|
+
return corpusKeyFor(payload) + TURN_PROMPT_SEPARATOR + String(turn);
|
|
453
|
+
}
|
|
454
|
+
/** Stash one turn's prompt, keeping insertion order as recency and bounding the map. */
|
|
455
|
+
function rememberTurnPrompt(payload, turn, prompt) {
|
|
456
|
+
const key = turnPromptKey(payload, turn);
|
|
457
|
+
pendingTurnPrompts.delete(key);
|
|
458
|
+
pendingTurnPrompts.set(key, prompt);
|
|
459
|
+
while (pendingTurnPrompts.size > PENDING_TURN_PROMPTS_MAX) {
|
|
460
|
+
const oldest = pendingTurnPrompts.keys().next().value;
|
|
461
|
+
if (oldest === undefined)
|
|
462
|
+
break;
|
|
463
|
+
pendingTurnPrompts.delete(oldest);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function accumulateCorpus(key, text) {
|
|
467
|
+
if (!text)
|
|
468
|
+
return;
|
|
469
|
+
const target = key || GLOBAL_CORPUS_KEY;
|
|
470
|
+
const existing = sessionPromptCorpus.get(target) ?? '';
|
|
471
|
+
if (existing.includes(text))
|
|
472
|
+
return;
|
|
473
|
+
const combined = existing ? `${existing}\n${text}` : text;
|
|
474
|
+
sessionPromptCorpus.set(target, combined.length > SESSION_CORPUS_MAX_CHARS
|
|
475
|
+
? combined.slice(combined.length - SESSION_CORPUS_MAX_CHARS)
|
|
476
|
+
: combined);
|
|
477
|
+
}
|
|
478
|
+
function extractTextFromClaimedMessages(messages) {
|
|
479
|
+
if (!Array.isArray(messages))
|
|
480
|
+
return '';
|
|
481
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
482
|
+
const m = messages[i];
|
|
483
|
+
if (!m)
|
|
484
|
+
continue;
|
|
485
|
+
if (typeof m.text === 'string' && m.text.trim())
|
|
486
|
+
return m.text.trim();
|
|
487
|
+
if (typeof m.prompt === 'string' && m.prompt.trim())
|
|
488
|
+
return m.prompt.trim();
|
|
489
|
+
if (typeof m.content === 'string' && m.content.trim())
|
|
490
|
+
return m.content.trim();
|
|
491
|
+
if (Array.isArray(m.content)) {
|
|
492
|
+
for (const block of m.content) {
|
|
493
|
+
if (block?.type === 'text' && typeof block?.text === 'string' && block.text.trim()) {
|
|
494
|
+
if (!block.text.startsWith('Current runtime context.')) {
|
|
495
|
+
return block.text.trim();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return '';
|
|
502
|
+
}
|
|
503
|
+
let isPluginApplied = false;
|
|
504
|
+
/**
|
|
505
|
+
* Print the measured reference throughput for the local worker models, so the
|
|
506
|
+
* server console carries a baseline next to the live per-call LEDGER_AUDIT rate.
|
|
507
|
+
*/
|
|
508
|
+
function logWorkerBenchmarks() {
|
|
509
|
+
console.log(`[WORKER_BENCH] Reference throughput (${profiles_1.WORKER_BENCHMARK_SOURCE})`);
|
|
510
|
+
console.log('[WORKER_BENCH] Local work is unmetered, so the metered plan is spent on thinking rather than on reading code.');
|
|
511
|
+
console.log('[WORKER_BENCH] LEDGER_AUDIT reports CloudEquiv: what the local tokens would have cost at the metered tier.');
|
|
512
|
+
console.log('[WORKER_BENCH] That is the plan exposure you avoided, not money saved -- the GPU is a fixed cost.');
|
|
513
|
+
console.log('[WORKER_BENCH] LEDGER_AUDIT carries running totals: Metered is what the plan paid, Local is what the GPU did.');
|
|
514
|
+
console.log('[WORKER_BENCH] model decode ttft note');
|
|
515
|
+
for (const bench of profiles_1.WORKER_BENCHMARKS) {
|
|
516
|
+
const active = bench.model === profiles_1.PROFILES.WORKER.model ? ' <- active worker' : '';
|
|
517
|
+
console.log(`[WORKER_BENCH] ${bench.model.padEnd(24)} ${`${bench.decodeTps.toFixed(1)} tok/s`.padEnd(13)} ${bench.ttft.padEnd(10)} ${bench.note}${active}`);
|
|
518
|
+
}
|
|
519
|
+
console.log(`[WORKER_BENCH] Active worker model: ${profiles_1.PROFILES.WORKER.model} (max_tokens: ${profiles_1.PROFILES.WORKER.max_tokens ?? 'unset'}, thinking: ${profiles_1.PROFILES.WORKER.enable_thinking === false ? 'off' : 'on'})`);
|
|
520
|
+
}
|
|
521
|
+
const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
|
|
522
|
+
/**
|
|
523
|
+
* Route an `ask` guard decision through the real approval seam.
|
|
524
|
+
*
|
|
525
|
+
* Returning `{ kind: 'ask' }` from a tools/pre-execute listener prompts nobody: the
|
|
526
|
+
* pipeline understands only `deny`, so every other kind is an allow. The seam that
|
|
527
|
+
* actually asks is `dsh-user-approval` (ctx.approval), and it fails closed — a
|
|
528
|
+
* missing service, a missing agent, an idle turn or a throwing answerer all resolve
|
|
529
|
+
* to 'unavailable', which this treats as a refusal.
|
|
530
|
+
*/
|
|
531
|
+
async function requestApprovalForWrite(ctx, exec, verdict) {
|
|
532
|
+
try {
|
|
533
|
+
const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
|
|
534
|
+
if (!service || typeof service.request !== 'function')
|
|
535
|
+
return 'unavailable';
|
|
536
|
+
if (!exec?.agent)
|
|
537
|
+
return 'unavailable';
|
|
538
|
+
const outcome = await service.request({
|
|
539
|
+
agent: exec.agent,
|
|
540
|
+
toolName: String(exec?.name || 'unknown'),
|
|
541
|
+
...(exec?.callId ? { callId: exec.callId } : {}),
|
|
542
|
+
reason: verdict.reason,
|
|
543
|
+
...(exec?.signal ? { signal: exec.signal } : {}),
|
|
544
|
+
});
|
|
545
|
+
// Validate against the vocabulary rather than trusting the answerer: the service
|
|
546
|
+
// normalises rogue values, but a bridge that assumes it would treat any unknown
|
|
547
|
+
// string as a decision. Anything unrecognised fails closed.
|
|
548
|
+
return typeof outcome === 'string' && APPROVAL_OUTCOMES.has(outcome)
|
|
549
|
+
? outcome
|
|
550
|
+
: 'unavailable';
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
// No open turn, suspended session, or a broken answerer: fail closed.
|
|
554
|
+
console.warn('[LOCAL_GUARD] approval request failed; failing closed:', err?.message || err);
|
|
555
|
+
return 'unavailable';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function resolveVerificationPolicy(options = {}) {
|
|
559
|
+
// A blank or non-string coherence command is absent, not an empty command: an empty string would run
|
|
560
|
+
// as a no-op subprocess and report success, which is a project check that checks nothing.
|
|
561
|
+
const coherence = typeof options.coherenceVerification === 'string' && options.coherenceVerification.trim()
|
|
562
|
+
? options.coherenceVerification.trim()
|
|
563
|
+
: undefined;
|
|
564
|
+
return {
|
|
565
|
+
mode: options.verificationApproval ?? 'ask',
|
|
566
|
+
allowlist: Array.isArray(options.verificationAllowlist) ? options.verificationAllowlist : [],
|
|
567
|
+
allowInProcessFallback: options.allowInProcessFallback === true,
|
|
568
|
+
timeoutMs: (0, verification_1.resolveVerificationTimeoutMs)(options.verificationTimeoutMs),
|
|
569
|
+
...(coherence ? { coherenceVerification: coherence } : {}),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Ask the operator to approve one model-selected verification command. Unlike the write
|
|
574
|
+
* guard this is not a `tools/pre-execute` decision, because the command runs after the
|
|
575
|
+
* worker responds; it is asked before dispatch so the operator sees it up front.
|
|
576
|
+
* Fails closed on every error path.
|
|
577
|
+
*/
|
|
578
|
+
async function requestApprovalForVerification(ctx, exec, command) {
|
|
579
|
+
try {
|
|
580
|
+
const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
|
|
581
|
+
if (!service || typeof service.request !== 'function')
|
|
582
|
+
return false;
|
|
583
|
+
if (!exec?.agent)
|
|
584
|
+
return false;
|
|
585
|
+
const outcome = await service.request({
|
|
586
|
+
agent: exec.agent,
|
|
587
|
+
toolName: 'delegate_worker',
|
|
588
|
+
...(exec?.callId ? { callId: exec.callId } : {}),
|
|
589
|
+
reason: `delegate_worker wants to run this verification command with the full authority of the DSH ` +
|
|
590
|
+
`process:\n ${command}\n` +
|
|
591
|
+
`It is model-selected and is not confined to the workspace. Approve it only if you recognise it.`,
|
|
592
|
+
...(exec?.signal ? { signal: exec.signal } : {}),
|
|
593
|
+
});
|
|
594
|
+
return outcome === 'allowed-once';
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
console.warn('[LOCAL_GUARD] verification approval request failed; failing closed:', err?.message || err);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Ask the operator before a delegation writes into a directory that is neither the session workspace
|
|
603
|
+
* nor an allowlisted root. Same seam as the verification prompt above, and the same fail-closed rule:
|
|
604
|
+
* no approval service, no agent, or a thrown request all resolve to refusal rather than to consent.
|
|
605
|
+
*/
|
|
606
|
+
async function requestApprovalForWorkspace(ctx, exec, dir, reason) {
|
|
607
|
+
try {
|
|
608
|
+
const service = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
|
|
609
|
+
if (!service || typeof service.request !== 'function')
|
|
610
|
+
return false;
|
|
611
|
+
if (!exec?.agent)
|
|
612
|
+
return false;
|
|
613
|
+
const outcome = await service.request({
|
|
614
|
+
agent: exec.agent,
|
|
615
|
+
toolName: 'delegate_worker',
|
|
616
|
+
...(exec?.callId ? { callId: exec.callId } : {}),
|
|
617
|
+
reason: 'delegate_worker wants to write into a directory outside the session workspace:\n ' +
|
|
618
|
+
dir +
|
|
619
|
+
'\n' +
|
|
620
|
+
reason +
|
|
621
|
+
'\nApprove only if you intend this delegation to write there.',
|
|
622
|
+
...(exec?.signal ? { signal: exec.signal } : {}),
|
|
623
|
+
});
|
|
624
|
+
return outcome === 'allowed-once';
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
console.warn('[LOCAL_GUARD] workspace approval request failed; failing closed:', err?.message || err);
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Role resolution, the architect config and the lead tier moved to ./roles.ts and are imported
|
|
632
|
+
// above. They are re-exported beside the other module re-exports because callers depend on them.
|
|
633
|
+
function apply(ctx, options = {}) {
|
|
634
|
+
// Restore what was delegated before this process started. Without this, a restart silently widened
|
|
635
|
+
// what the architect may read back — the gap a live run found, and the reason this is not merely
|
|
636
|
+
// in-memory state any more.
|
|
637
|
+
for (const record of (0, contracts_1.loadDelegatedRegistry)()) {
|
|
638
|
+
if (record.mode === 'created')
|
|
639
|
+
contracts_1.delegatedPaths.add((0, paths_1.canonicalisePath)(record.path));
|
|
640
|
+
}
|
|
641
|
+
const REGISTERED_KEY = Symbol.for('dsh-plugin-coding-delegate.registered');
|
|
642
|
+
const isTest = process.env.NODE_ENV === 'test';
|
|
643
|
+
if (!isTest) {
|
|
644
|
+
if (isPluginApplied || ctx[REGISTERED_KEY] || globalThis[REGISTERED_KEY]) {
|
|
645
|
+
console.warn('[LOCAL_ROUTER] Plugin already registered. Skipping duplicate mounting.');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
isPluginApplied = true;
|
|
649
|
+
ctx[REGISTERED_KEY] = true;
|
|
650
|
+
globalThis[REGISTERED_KEY] = true;
|
|
651
|
+
}
|
|
652
|
+
console.log('[LOCAL_ROUTER_DEBUG] ctx.tools available:', Boolean(ctx.tools));
|
|
653
|
+
const router = new LocalRouter(options);
|
|
654
|
+
const config = router.getConfig();
|
|
655
|
+
const tracker = new savings_tracker_1.SavingsTracker((0, logging_1.resolveDataDir)());
|
|
656
|
+
(0, logging_1.trace)('PLUGIN_INIT_ASYMMETRIC_ORCHESTRATOR', { config });
|
|
657
|
+
// Register `delegate_worker` strictly adhering to `@deepseek-ai/dsh-tools` and DeepSeek JSON Schema contract
|
|
658
|
+
if (ctx.tools && typeof ctx.tools.register === 'function') {
|
|
659
|
+
try {
|
|
660
|
+
const dshToolDef = {
|
|
661
|
+
name: 'delegate_worker',
|
|
662
|
+
description: 'Dispatches a discrete implementation, testing, or code-generation task to the configured local execution worker -- any OpenAI-compatible server (LM Studio, Ollama, vLLM, llama.cpp) -- with an isolated context window. The worker has no repository read: declare contextFiles for the code it must see, since it cannot discover anything itself.',
|
|
663
|
+
// SINGLE SOURCE OF TRUTH. These parameters were previously duplicated here and in
|
|
664
|
+
// DELEGATE_WORKER_OPENAI_SCHEMA in delegation.ts, and the duplication already cost a false test:
|
|
665
|
+
// adding attestTargets/attestEvidence to this copy alone left the tool listing unchanged and the
|
|
666
|
+
// delegated call was silently stripped of both -- the tool reported success while the parameters
|
|
667
|
+
// never arrived. delegation.ts owns the list; this file references it.
|
|
668
|
+
parameters: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA.function.parameters,
|
|
669
|
+
output: {
|
|
670
|
+
schema: {
|
|
671
|
+
type: 'object',
|
|
672
|
+
additionalProperties: true,
|
|
673
|
+
},
|
|
674
|
+
render: (_args, value) => [
|
|
675
|
+
{
|
|
676
|
+
type: 'text',
|
|
677
|
+
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
},
|
|
681
|
+
async execute(args, exec) {
|
|
682
|
+
const resolved = resolveWorkspaceDir(ctx);
|
|
683
|
+
const explicitDir = args?.workspaceDir;
|
|
684
|
+
// `endpoint` is not a declared tool argument and is deliberately dropped:
|
|
685
|
+
// delegateWorker would otherwise POST the task to whatever URL a caller named.
|
|
686
|
+
const { endpoint: _ignoredEndpoint, ...callerArgs } = args || {};
|
|
687
|
+
// Attestation is a SEPARATE act from delegation and must not be smuggled into it. A caller may
|
|
688
|
+
// name paths to attest here so the operator's own review can be recorded through a tool call
|
|
689
|
+
// rather than a shell one-liner -- the manual route was the only one available, which is exactly
|
|
690
|
+
// why the promotion socket sat patched-but-unattested for so long.
|
|
691
|
+
const { attestTargets, attestEvidence, attestOperator, ...delegationArgs } = callerArgs;
|
|
692
|
+
const policy = resolveVerificationPolicy(options);
|
|
693
|
+
// The workspaceDir ARGUMENT chooses the base directory for every containment check, and it is
|
|
694
|
+
// model-visible. Containment of the emitted files was always sound; what was missing is that
|
|
695
|
+
// the base itself was supplied by the caller being contained, so naming a directory outside
|
|
696
|
+
// the session workspace made every later boundary true by construction.
|
|
697
|
+
const containment = (0, containment_1.resolveWorkspaceContainment)({
|
|
698
|
+
requested: explicitDir,
|
|
699
|
+
sessionRoot: resolved.dir,
|
|
700
|
+
allowedRoots: options?.emitAllowlist,
|
|
701
|
+
canPrompt: typeof ctx?.get === 'function' && Boolean(ctx.get('approval')),
|
|
702
|
+
});
|
|
703
|
+
if (!containment.trusted && !containment.needsApproval) {
|
|
704
|
+
return {
|
|
705
|
+
success: false,
|
|
706
|
+
status: 'CONTEXT_REFUSED',
|
|
707
|
+
taskName: String(args?.taskName || ''),
|
|
708
|
+
message: String(containment.reason),
|
|
709
|
+
error: 'workspaceDir refused',
|
|
710
|
+
resolvedWorkspace: resolved.dir,
|
|
711
|
+
filesWritten: [],
|
|
712
|
+
testResults: { passed: 0, failed: 0, output: 'The worker was not called.' },
|
|
713
|
+
tokens: { prompt: 0, completion: 0 },
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (!containment.trusted && containment.needsApproval) {
|
|
717
|
+
const approved = await requestApprovalForWorkspace(ctx, exec, String(containment.dir), String(containment.reason));
|
|
718
|
+
if (!approved) {
|
|
719
|
+
return {
|
|
720
|
+
success: false,
|
|
721
|
+
status: 'CONTEXT_REFUSED',
|
|
722
|
+
taskName: String(args?.taskName || ''),
|
|
723
|
+
message: String(containment.reason),
|
|
724
|
+
error: 'workspaceDir refused: operator approval was not granted',
|
|
725
|
+
resolvedWorkspace: resolved.dir,
|
|
726
|
+
filesWritten: [],
|
|
727
|
+
testResults: { passed: 0, failed: 0, output: 'The worker was not called.' },
|
|
728
|
+
tokens: { prompt: 0, completion: 0 },
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const verdict = await (0, delegation_1.delegateWorker)({
|
|
733
|
+
...delegationArgs,
|
|
734
|
+
// Operator settings, not caller arguments. The local endpoint and model are
|
|
735
|
+
// trusted configuration; a caller-supplied `endpoint` was dropped just above.
|
|
736
|
+
...(options?.localEndpoint ? { endpoint: options.localEndpoint } : {}),
|
|
737
|
+
...(options?.localModel ? { model: options.localModel } : {}),
|
|
738
|
+
workspaceDir: explicitDir || resolved.dir,
|
|
739
|
+
workspaceSource: explicitDir ? 'caller-supplied workspaceDir' : resolved.source,
|
|
740
|
+
verificationPolicy: policy,
|
|
741
|
+
emitAllowlist: options?.emitAllowlist,
|
|
742
|
+
// Operator setting, placed after the caller args for the same reason as `unitScope` below:
|
|
743
|
+
// a caller must not be able to switch this off, and an operator who has switched it off must
|
|
744
|
+
// not have it switched back on by a caller.
|
|
745
|
+
retryContext: options?.retryContext ?? 'auto',
|
|
746
|
+
verificationApproval: (command) => requestApprovalForVerification(ctx, exec, command),
|
|
747
|
+
}, tracker);
|
|
748
|
+
if (Array.isArray(attestTargets) && attestTargets.length > 0) {
|
|
749
|
+
// Only a PASSING unit may be attested from here. An attestation is a human claim about content;
|
|
750
|
+
// attaching one to a unit that failed or was never verified would launder a machine outcome
|
|
751
|
+
// into a human statement, which is the one thing this verdict must not become.
|
|
752
|
+
//
|
|
753
|
+
// The gate is `status === 'SUCCESS'`, NOT 'UNIT_PASSED'. The first version of this compared
|
|
754
|
+
// against 'UNIT_PASSED' and refused every attestation, because `resolveDelegateStatus` returns
|
|
755
|
+
// 'SUCCESS' / 'INCOHERENT' / 'VERIFICATION_FAILED' / 'UNVERIFIED' / 'FLAKY' and never emits the
|
|
756
|
+
// registry's outcome vocabulary at all. The two vocabularies are genuinely different, and the
|
|
757
|
+
// mistake was silent in the worst way: the tool reported success while quietly skipping the
|
|
758
|
+
// attestation, and the skip message blamed the unit rather than the comparison.
|
|
759
|
+
//
|
|
760
|
+
// `SUCCESS` is also the stricter of the two candidate gates, and correctly so: it requires the
|
|
761
|
+
// project coherence check to have passed as well, and attesting a unit whose tree is broken
|
|
762
|
+
// would certify content in a state nothing verified.
|
|
763
|
+
if (verdict && verdict.status === 'SUCCESS') {
|
|
764
|
+
// OPERATOR IDENTITY, and the first version of this got it exactly wrong. It fell back to
|
|
765
|
+
// `exec.agent.id`, which stamped an ephemeral session UUID into the audit ledger as though a
|
|
766
|
+
// human had signed off -- inverting the separation of duties the verdict exists to record,
|
|
767
|
+
// and producing a record LESS attributable than a typed name rather than more.
|
|
768
|
+
//
|
|
769
|
+
// There is deliberately NO fallback to the agent. An autonomous agent id cannot attest human
|
|
770
|
+
// review, so the absence of an identity is a refusal, not a value to invent. Precedence is
|
|
771
|
+
// the call's `attestOperator`, then the plugin's `operatorIdentity` -- explicit over ambient.
|
|
772
|
+
const operator = String(attestOperator || options?.operatorIdentity || '').trim();
|
|
773
|
+
// Scope before attesting: a path may be attested only if this unit actually wrote it.
|
|
774
|
+
// Attesting anything else is how a caller forged a human review over a file the unit
|
|
775
|
+
// never touched. Placed after `operator` because the helper needs it.
|
|
776
|
+
const scope = (0, attestation_1.selectAttestableTargets)({
|
|
777
|
+
requested: Array.isArray(attestTargets) ? attestTargets : [],
|
|
778
|
+
filesWritten: Array.isArray(verdict?.filesWritten) ? verdict.filesWritten : [],
|
|
779
|
+
operator,
|
|
780
|
+
});
|
|
781
|
+
if (scope.refused.length > 0) {
|
|
782
|
+
verdict.attestationErrors = scope.refused.map((p) => 'attestTargets must be written and verified in the current unit: ' + p);
|
|
783
|
+
}
|
|
784
|
+
if (!operator) {
|
|
785
|
+
// Refused, and the unit's own verdict is left INTACT. Throwing here would destroy a passing
|
|
786
|
+
// unit's result over a bookkeeping failure, which is not what failing closed means at this
|
|
787
|
+
// seam: the delegation succeeded, and only the attestation did not.
|
|
788
|
+
verdict.attestationSkipped =
|
|
789
|
+
'attestTargets was provided but no operator identity is available, so nothing was attested. ' +
|
|
790
|
+
"Pass attestOperator on the call, or set 'operatorIdentity' in the plugin configuration. " +
|
|
791
|
+
'An autonomous agent id cannot attest human review, so it is never used as a fallback.';
|
|
792
|
+
console.warn('[LOCAL_EGRESS] attestation refused: no operator identity (attestOperator or operatorIdentity)');
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
const evidence = String(attestEvidence || '').trim() ||
|
|
796
|
+
'operator reviewed the passing unit from delegation ' + String(verdict.taskName || '');
|
|
797
|
+
const attested = [];
|
|
798
|
+
// Only what this unit wrote, so an answer-only delegation attests nothing.
|
|
799
|
+
for (const target of scope.attestable) {
|
|
800
|
+
try {
|
|
801
|
+
const record = (0, contracts_1.recordOperatorAttestation)(String(target), operator, evidence);
|
|
802
|
+
if (record)
|
|
803
|
+
attested.push(record.path);
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
// The recorder throws when the registry cannot be written, deliberately: an unpersisted
|
|
807
|
+
// attestation must not be reported as one. Surfacing it here keeps that contract.
|
|
808
|
+
console.warn('[LOCAL_EGRESS] attestation failed for', target, err?.message || err);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
verdict.attested = attested;
|
|
812
|
+
if (attested.length > 0) {
|
|
813
|
+
verdict.summary = String(verdict.summary || '') + '\nAttested: ' + attested.join(', ') + '.';
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
verdict.attestationSkipped =
|
|
819
|
+
'attestTargets was provided but the unit status was ' +
|
|
820
|
+
String(verdict && verdict.status) +
|
|
821
|
+
', not SUCCESS, so nothing was attested';
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return verdict;
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
// DRIFT ASSERTION for the duplicated tool schema, and it exists because the duplication already cost
|
|
828
|
+
// a false test. The parameters are declared twice: once inline here (the dev contract this file
|
|
829
|
+
// registers) and once in `DELEGATE_WORKER_OPENAI_SCHEMA` in delegation.ts, which is what the host
|
|
830
|
+
// receives. Adding `attestTargets`/`attestEvidence` to the inline copy alone left the tool listing
|
|
831
|
+
// unchanged, and the delegated call was SILENTLY stripped of both -- the tool reported success while
|
|
832
|
+
// the parameters never arrived.
|
|
833
|
+
//
|
|
834
|
+
// `delegation.ts` owns the canonical list. This asserts the local copy still agrees with it, so the
|
|
835
|
+
// next parameter added to one and not the other fails loudly at registration instead of quietly
|
|
836
|
+
// dropping an argument. Sorted key comparison plus the `required` list, because a missing parameter
|
|
837
|
+
// and a reordered one are not the same defect and only the first is worth waking anyone for.
|
|
838
|
+
{
|
|
839
|
+
const canonical = delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA?.function?.parameters;
|
|
840
|
+
const inlineParams = dshToolDef?.parameters;
|
|
841
|
+
if (canonical && canonical.properties && inlineParams && inlineParams.properties) {
|
|
842
|
+
const a = Object.keys(canonical.properties).sort().join(',');
|
|
843
|
+
const b = Object.keys(inlineParams.properties).sort().join(',');
|
|
844
|
+
const reqA = JSON.stringify(canonical.required || []);
|
|
845
|
+
const reqB = JSON.stringify(inlineParams.required || []);
|
|
846
|
+
if (a !== b || reqA !== reqB) {
|
|
847
|
+
throw new Error('delegate_worker schema drift: the inline parameters in index.ts disagree with ' +
|
|
848
|
+
'DELEGATE_WORKER_OPENAI_SCHEMA in delegation.ts. ' +
|
|
849
|
+
'Canonical: [' +
|
|
850
|
+
a +
|
|
851
|
+
'] required ' +
|
|
852
|
+
reqA +
|
|
853
|
+
'. Inline: [' +
|
|
854
|
+
b +
|
|
855
|
+
'] required ' +
|
|
856
|
+
reqB +
|
|
857
|
+
'. Add the parameter to BOTH or neither -- a parameter present in only one is silently ' +
|
|
858
|
+
'dropped from the tool call, which is how attestTargets was lost the first time.');
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
;
|
|
864
|
+
ctx.tools.register(dshToolDef);
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
;
|
|
868
|
+
ctx.tools.register('delegate_worker', dshToolDef, dshToolDef.execute);
|
|
869
|
+
}
|
|
870
|
+
console.log("[LOCAL_ROUTER_INIT] Tool 'delegate_worker' registered successfully on ctx.tools.");
|
|
871
|
+
logWorkerBenchmarks();
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
console.warn("[LOCAL_ROUTER_INIT] Failed to register tool via ctx.tools.register:", e?.message || String(e));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
// There is NO fallback. The listener that used to sit here subscribed to `ctx.on('tool/call')`, which
|
|
879
|
+
// is not a Cordis hook at all: `tool/call` is a SESSION EVENT type, delivered through `session/event`,
|
|
880
|
+
// and an observer of the session log cannot answer a tool invocation in any case. So the fallback
|
|
881
|
+
// could never have run, and if `ctx.tools.register` ever fails this plugin simply has no tool.
|
|
882
|
+
console.warn('[LOCAL_ROUTER_INIT] ctx.tools is unavailable. There is no fallback path: this plugin can only ' +
|
|
883
|
+
'serve delegate_worker through ctx.tools.register.');
|
|
884
|
+
}
|
|
885
|
+
// Local-only code guard: refuse cloud-authored source writes so that all code
|
|
886
|
+
// work routes through delegate_worker to the local worker.
|
|
887
|
+
if (options?.localCodeGuard !== false) {
|
|
888
|
+
// Parameters inferred from the host's `tools/pre-execute` signature: `(exec: ToolExecution, next)`.
|
|
889
|
+
(0, host_events_1.onHost)(ctx, 'tools/pre-execute', async (exec, next) => {
|
|
890
|
+
// `as const` keeps `kind` the literal the host's `PreToolDecision` requires. Without it the object
|
|
891
|
+
// widens to `{ kind: string }` and the whole handler stops satisfying the host's signature -- which
|
|
892
|
+
// is what made this one line worth checking rather than assuming.
|
|
893
|
+
const decision = typeof next === 'function' ? await next() : { kind: 'allow' };
|
|
894
|
+
if (!decision || decision.kind !== 'allow')
|
|
895
|
+
return decision;
|
|
896
|
+
try {
|
|
897
|
+
const verdict = (0, guard_1.evaluateCodeWriteGuard)(exec, {
|
|
898
|
+
askPaths: options?.guardAskPaths,
|
|
899
|
+
delegatedPaths: contracts_1.delegatedPaths,
|
|
900
|
+
delegateReadPolicy: options?.delegateReadPolicy,
|
|
901
|
+
contractPaths: options?.contractPaths,
|
|
902
|
+
contractWriteMode: options?.contractWriteMode,
|
|
903
|
+
});
|
|
904
|
+
if (verdict) {
|
|
905
|
+
// guardMode 'deny' wins outright: an operator who said "never prompt" must
|
|
906
|
+
// not be prompted just because this path would otherwise be ask-eligible.
|
|
907
|
+
const wantsAsk = options?.guardMode !== 'deny' && (options?.guardMode === 'ask' || verdict.kind === 'ask');
|
|
908
|
+
if (!wantsAsk) {
|
|
909
|
+
console.log(`[LOCAL_GUARD] DENY ${exec?.name} -> ${verdict.target}`);
|
|
910
|
+
return { kind: 'deny', reason: verdict.reason };
|
|
911
|
+
}
|
|
912
|
+
// Ask for real, through the approval seam. Only 'allowed-once' grants.
|
|
913
|
+
const outcome = await requestApprovalForWrite(ctx, exec, verdict);
|
|
914
|
+
if (outcome === 'allowed-once') {
|
|
915
|
+
console.log(`[LOCAL_GUARD] ALLOWED-ONCE ${exec?.name} -> ${verdict.target}`);
|
|
916
|
+
return decision;
|
|
917
|
+
}
|
|
918
|
+
console.log(`[LOCAL_GUARD] DENY (${outcome}) ${exec?.name} -> ${verdict.target}`);
|
|
919
|
+
return {
|
|
920
|
+
kind: 'deny',
|
|
921
|
+
reason: `${verdict.reason} (approval outcome: ${outcome})`,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
// Observation, not enforcement: the architect is allowed to read source, and this records it.
|
|
925
|
+
// The guard above answers "may this happen"; this answers "did it, and who by" — which is what
|
|
926
|
+
// will later say whether that access is used at all, and so whether it can be taken away.
|
|
927
|
+
const observed = (0, roles_1.describeSourceRead)({
|
|
928
|
+
tool: exec?.name,
|
|
929
|
+
target: (0, guard_1.extractWriteTarget)(exec?.arguments),
|
|
930
|
+
// The live agent is passed as well as its id, so the guard can read the host's session lineage
|
|
931
|
+
// instead of relying only on the observed correlation. That is the whole of item 1: before
|
|
932
|
+
// `Session.header` existed there was nothing to read, and the plugin said so.
|
|
933
|
+
role: (0, roles_1.roleForAgent)(exec?.agent?.id, exec?.agent),
|
|
934
|
+
});
|
|
935
|
+
if (observed.track) {
|
|
936
|
+
(0, logging_1.trace)('SOURCE_READ', {
|
|
937
|
+
role: observed.role,
|
|
938
|
+
agent: String(exec?.agent?.id ?? '').slice(0, 8) || 'unknown',
|
|
939
|
+
tool: String(exec?.name ?? ''),
|
|
940
|
+
target: observed.target,
|
|
941
|
+
extension: observed.extension,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
// Fail closed: a guard that cannot evaluate must not wave the call through.
|
|
947
|
+
console.warn('[LOCAL_GUARD] evaluation failed; failing closed:', err);
|
|
948
|
+
return {
|
|
949
|
+
kind: 'deny',
|
|
950
|
+
reason: `Local-code guard could not evaluate this call: ${err?.message || err}`,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return decision;
|
|
954
|
+
});
|
|
955
|
+
console.log('[LOCAL_ROUTER_INIT] Local-code guard registered on tools/pre-execute.');
|
|
956
|
+
}
|
|
957
|
+
// Egress control for READS: serve the type skeleton instead of the implementation.
|
|
958
|
+
//
|
|
959
|
+
// The write guard answers "may source be authored". This answers "may implementation BODIES leave the
|
|
960
|
+
// machine", which is a different question and was previously unanswered -- the architect could read any
|
|
961
|
+
// file it liked and nothing constrained it. It cannot be done at `tools/pre-execute`, because that
|
|
962
|
+
// decision type is only allow/deny/ask and rewriting arguments is excluded by the host's own comment on
|
|
963
|
+
// `PreToolDecision`. `tools/post-execute` can replace the model-facing content, which is exactly "serve
|
|
964
|
+
// the declaration instead", so that is where it lives.
|
|
965
|
+
if (options?.sourceReadEgress === 'declarations') {
|
|
966
|
+
const declarationRoot = options?.declarationRoot;
|
|
967
|
+
if (!declarationRoot || !String(declarationRoot).trim()) {
|
|
968
|
+
console.warn('[LOCAL_EGRESS] sourceReadEgress is "declarations" but no declarationRoot was configured, so reads are ' +
|
|
969
|
+
'being SERVED AS SOURCE. Set declarationRoot, or this setting does nothing.');
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
(0, host_events_1.onHost)(ctx, 'tools/post-execute', async (exec, result, next) => {
|
|
973
|
+
const decision = typeof next === 'function' ? await next() : { kind: 'accept' };
|
|
974
|
+
if (!decision || decision.kind !== 'accept')
|
|
975
|
+
return decision;
|
|
976
|
+
try {
|
|
977
|
+
const name = String(exec?.name || '');
|
|
978
|
+
const egressDecision = (0, declaration_egress_hook_1.handleDeclarationPostExecute)(exec, options);
|
|
979
|
+
if (egressDecision)
|
|
980
|
+
return egressDecision;
|
|
981
|
+
if (!guard_1.READ_TOOLS.has(name))
|
|
982
|
+
return decision;
|
|
983
|
+
if (result?.isError)
|
|
984
|
+
return decision;
|
|
985
|
+
const target = (0, guard_1.extractWriteTarget)(exec?.arguments);
|
|
986
|
+
if (!target)
|
|
987
|
+
return decision;
|
|
988
|
+
const skeleton = (0, guard_1.declarationPathFor)(target, String(declarationRoot));
|
|
989
|
+
// Not a source file: nothing to strip, so the read stands.
|
|
990
|
+
if (!skeleton)
|
|
991
|
+
return decision;
|
|
992
|
+
// STALENESS. Until this check existed, the egress control trusted the build: it verified that a
|
|
993
|
+
// declaration EXISTED at the derived path, never that the declaration described the source being
|
|
994
|
+
// read. Edit an interface, skip the rebuild, and the architect would be served yesterday's
|
|
995
|
+
// signature while believing it was current -- green for the same reason every false green here has
|
|
996
|
+
// been green, because nothing checked the thing that mattered.
|
|
997
|
+
//
|
|
998
|
+
// Read the predicate rather than the summary: mtime is evidence of a rebuild, not proof of a
|
|
999
|
+
// match, and `staleDeclarationReason` says so at length.
|
|
1000
|
+
const stale = (0, guard_1.staleDeclarationReason)(target, skeleton);
|
|
1001
|
+
if (stale) {
|
|
1002
|
+
return {
|
|
1003
|
+
kind: 'block',
|
|
1004
|
+
feedback: [
|
|
1005
|
+
{
|
|
1006
|
+
type: 'text',
|
|
1007
|
+
text: "Reading '" +
|
|
1008
|
+
target +
|
|
1009
|
+
"' was refused: " +
|
|
1010
|
+
stale +
|
|
1011
|
+
'. Rebuild the declarations (tsc --declaration --emitDeclarationOnly) and read again. ' +
|
|
1012
|
+
'Serving a stale declaration would let this context plan against a contract the code no ' +
|
|
1013
|
+
'longer implements.',
|
|
1014
|
+
},
|
|
1015
|
+
],
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
let content;
|
|
1019
|
+
try {
|
|
1020
|
+
content = fs.readFileSync(skeleton, 'utf8');
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
// Fail closed. A file with no emitted declaration is refused rather than served as source,
|
|
1024
|
+
// because serving it would be the exact leak this control exists to prevent.
|
|
1025
|
+
return {
|
|
1026
|
+
kind: 'block',
|
|
1027
|
+
feedback: [
|
|
1028
|
+
{
|
|
1029
|
+
type: 'text',
|
|
1030
|
+
text: "Reading '" +
|
|
1031
|
+
target +
|
|
1032
|
+
"' was refused: sourceReadEgress is 'declarations' and no type skeleton exists at '" +
|
|
1033
|
+
skeleton +
|
|
1034
|
+
". " +
|
|
1035
|
+
// A TEST FILE HAS NO DECLARATION BY CONSTRUCTION. tsconfig.json sets rootDir ./src and
|
|
1036
|
+
// include [src/**/*], so a file under tests/ is outside the emit root and NO build will ever
|
|
1037
|
+
// produce its .d.ts. The original message told the reader to rebuild and retry, which is
|
|
1038
|
+
// unachievable for exactly the files most likely to be refused -- a remediation loop with no
|
|
1039
|
+
// exit. Separators are doubled below because this is a regex literal.
|
|
1040
|
+
(/(?:^|[\\/])tests?(?:[\\/]|$)/i.test(target)
|
|
1041
|
+
? 'Test files live outside the declaration root and emit no skeleton, so no build will produce one. Inspect it in a local editor, or turn sourceReadEgress off in the profile patch (which needs a DSH restart).'
|
|
1042
|
+
: 'Build the declarations with npm run build, or read the declaration path directly.') +
|
|
1043
|
+
' Implementation bodies are not served to this context.',
|
|
1044
|
+
},
|
|
1045
|
+
],
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
(0, logging_1.trace)('SOURCE_DECLARATION_SERVED', {
|
|
1049
|
+
tool: name,
|
|
1050
|
+
requested: String(exec?.arguments?.file_path || exec?.arguments?.path || target),
|
|
1051
|
+
served: skeleton,
|
|
1052
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
kind: 'accept',
|
|
1056
|
+
content: [
|
|
1057
|
+
{
|
|
1058
|
+
type: 'text',
|
|
1059
|
+
text: '// SERVED AS TYPE DECLARATIONS, NOT SOURCE. Implementation bodies are withheld by policy ' +
|
|
1060
|
+
'(sourceEgress: declarations).\n// Declaration file: ' +
|
|
1061
|
+
skeleton +
|
|
1062
|
+
'\n\n' +
|
|
1063
|
+
content,
|
|
1064
|
+
},
|
|
1065
|
+
],
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
catch (err) {
|
|
1069
|
+
// Fail closed for the same reason the write guard does: an egress filter that cannot evaluate
|
|
1070
|
+
// must not wave the read through.
|
|
1071
|
+
console.warn('[LOCAL_EGRESS] evaluation failed; refusing the read:', err);
|
|
1072
|
+
return {
|
|
1073
|
+
kind: 'block',
|
|
1074
|
+
feedback: [
|
|
1075
|
+
{ type: 'text', text: 'Read refused: the source-egress filter could not evaluate this call.' },
|
|
1076
|
+
],
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
console.log('[LOCAL_ROUTER_INIT] Source egress control registered on tools/post-execute.');
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// 1. Lightweight agent/pre-step prompt capture & DLP scanner ONLY
|
|
1084
|
+
(0, host_events_1.onHost)(ctx, 'agent/pre-step', async (payload, next) => {
|
|
1085
|
+
const turn = payload?.turn;
|
|
1086
|
+
const prompt = extractTextFromClaimedMessages(payload?.messages);
|
|
1087
|
+
if (turn !== undefined && prompt) {
|
|
1088
|
+
rememberTurnPrompt(payload, turn, prompt);
|
|
1089
|
+
accumulateCorpus(corpusKeyFor(payload), prompt);
|
|
1090
|
+
(0, logging_1.trace)('HOOK_CAPTURE: PROMPT_CAPTURED (agent/pre-step)', {
|
|
1091
|
+
turn,
|
|
1092
|
+
prompt: prompt.slice(0, 100),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
// The host types `next` as always a function, so the `payload` branch is a runtime guard the types
|
|
1096
|
+
// cannot see through -- and `payload` is not a `PreStepDecision`, so it never was a valid answer.
|
|
1097
|
+
// Asserted rather than deleted: if the guard ever fires, passing the payload through is still the
|
|
1098
|
+
// least surprising thing to do, and this line now says out loud that it is unverified.
|
|
1099
|
+
return typeof next === 'function' ? await next() : payload;
|
|
1100
|
+
}, { prepend: true });
|
|
1101
|
+
// Context-quality counters: the measurement that turns "your GPU does the typing" into an observation
|
|
1102
|
+
// rather than an argument.
|
|
1103
|
+
//
|
|
1104
|
+
// DSH publishes every session event on a `session/event` firehose, and its own session service
|
|
1105
|
+
// documents observation as "a plugin concern (subscribe to `session/event`)". So this needs no injected
|
|
1106
|
+
// service, which is what keeps the plugin's `inject` list -- and the oracle that asserts it -- unchanged.
|
|
1107
|
+
//
|
|
1108
|
+
// Counted per session, traced on the events that move the figures. The reclaimed-token number comes
|
|
1109
|
+
// from the host (`shadowedTokenCount` on `compaction/summary` and `compaction/prune`), so it is
|
|
1110
|
+
// reported rather than estimated.
|
|
1111
|
+
//
|
|
1112
|
+
// One limit worth knowing while reading the trace: events that entered through construction (replay,
|
|
1113
|
+
// fork, resume) are not published on this firehose, so a resumed session is counted from the resume
|
|
1114
|
+
// rather than from its true beginning. Recorded in `docs/findings.md`.
|
|
1115
|
+
const contextQuality = new Map();
|
|
1116
|
+
const qualityKeyFor = (session) => {
|
|
1117
|
+
const id = session?.id ?? session?.header?.id;
|
|
1118
|
+
return typeof id === 'string' && id.length > 0 ? id : '__global__';
|
|
1119
|
+
};
|
|
1120
|
+
// Parameters inferred from the host's `session/event` signature: `(session: Session, event: SessionEvent)`.
|
|
1121
|
+
(0, host_events_1.onHost)(ctx, 'session/event', (session, event) => {
|
|
1122
|
+
const key = qualityKeyFor(session);
|
|
1123
|
+
const current = contextQuality.get(key) ?? context_quality_1.EMPTY_CONTEXT_QUALITY;
|
|
1124
|
+
const next = (0, context_quality_1.foldContextQuality)(current, event);
|
|
1125
|
+
if (next === current)
|
|
1126
|
+
return;
|
|
1127
|
+
contextQuality.set(key, next);
|
|
1128
|
+
const type = String(event?.type ?? '');
|
|
1129
|
+
// The traced subset lives in `session-events.ts` beside the folded vocabulary, where both lists are
|
|
1130
|
+
// checked against the host's own `SessionEventMap`. Two hand-written copies of these names used to sit
|
|
1131
|
+
// in this plugin, and only one of them was ever going to get updated.
|
|
1132
|
+
if (session_events_1.TRACED_EVENTS.has(type)) {
|
|
1133
|
+
(0, logging_1.trace)('CONTEXT_QUALITY', {
|
|
1134
|
+
// DSH ids are `session-<uuid>`, so the first eight characters are the constant prefix and
|
|
1135
|
+
// identify nothing -- every session logged as "session-". Take the eight AFTER the prefix.
|
|
1136
|
+
session: key.startsWith('session-') ? key.slice(8, 16) : key.slice(0, 8),
|
|
1137
|
+
event: type,
|
|
1138
|
+
summary: (0, context_quality_1.describeContextQuality)(next),
|
|
1139
|
+
});
|
|
1140
|
+
// Once per turn, the compounding figure: content the worker produced and the architect never carried,
|
|
1141
|
+
// multiplied by the model calls it would have ridden on. The currency is context, not money -- see
|
|
1142
|
+
// `docs/experiment.md` for why the ledger stopped quoting dollars. `turn/start` is used because it is
|
|
1143
|
+
// already folded and traced, so this costs one branch rather than a second event subscription.
|
|
1144
|
+
if (type === 'turn/start') {
|
|
1145
|
+
(0, logging_1.trace)('CONTEXT_HYGIENE', {
|
|
1146
|
+
session: key.startsWith('session-') ? key.slice(8, 16) : key.slice(0, 8),
|
|
1147
|
+
line: tracker.contextHygiene(next.steps).line,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
// 2. Primary Thread (Architect) Request Hook: Pin primary thread to DeepSeek Cloud with native uncapped context and tool schema injection
|
|
1153
|
+
(0, host_events_1.onHost)(ctx, 'agent/request',
|
|
1154
|
+
// Parameters deliberately NOT annotated: both are inferred from the host's own signature for
|
|
1155
|
+
// `agent/request`, so reading a field the host does not send is now a compile error here.
|
|
1156
|
+
async (payload, next) => {
|
|
1157
|
+
// `next` is typed by the host as always a function, so the `{}` fallback is a runtime guard the
|
|
1158
|
+
// types cannot see through. Widening this local keeps the guard without pretending the branch is
|
|
1159
|
+
// reachable; everything else in this handler stays typed.
|
|
1160
|
+
const resolvedConfig = typeof next === 'function' ? await next() : {};
|
|
1161
|
+
const turn = payload?.turn;
|
|
1162
|
+
const agent = payload?.agent;
|
|
1163
|
+
let prompt = (turn !== undefined ? pendingTurnPrompts.get(turnPromptKey(payload, turn)) : '') || '';
|
|
1164
|
+
if (turn !== undefined) {
|
|
1165
|
+
pendingTurnPrompts.delete(turnPromptKey(payload, turn));
|
|
1166
|
+
}
|
|
1167
|
+
if (!prompt) {
|
|
1168
|
+
try {
|
|
1169
|
+
if (agent?.inbox?.nextTurn && Array.isArray(agent.inbox.nextTurn) && agent.inbox.nextTurn.length > 0) {
|
|
1170
|
+
const item = agent.inbox.nextTurn[agent.inbox.nextTurn.length - 1];
|
|
1171
|
+
prompt = item?.prompt || item?.text || item?.content || '';
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
catch { }
|
|
1175
|
+
}
|
|
1176
|
+
if (!prompt && agent?.session)
|
|
1177
|
+
prompt = (0, delegation_1.extractPromptText)(agent.session);
|
|
1178
|
+
// `payload?.session` used to be tried here. The host's `agent/request` payload is
|
|
1179
|
+
// `{ agent, turn, step, signal }` -- there is no `session` on it, so that fallback never matched a
|
|
1180
|
+
// single request. Found by typing the handler against the host's own signature rather than by review.
|
|
1181
|
+
if (!prompt && payload)
|
|
1182
|
+
prompt = (0, delegation_1.extractPromptText)(payload);
|
|
1183
|
+
// Pre-flight DLP Firewall. This is a GATE, not a log line: a payload carrying
|
|
1184
|
+
// credentials is either refused outright or pinned to the local worker, but it
|
|
1185
|
+
// is never transmitted to the cloud. (Earlier versions logged "Blocking WAN
|
|
1186
|
+
// transmission" and then sent the payload anyway.)
|
|
1187
|
+
// Scan everything this session has said, not just the newest message: the host
|
|
1188
|
+
// re-sends the conversation on every request, so a clean latest message is not
|
|
1189
|
+
// evidence that the outbound payload is clean.
|
|
1190
|
+
const corpus = sessionPromptCorpus.get(corpusKeyFor(payload)) || '';
|
|
1191
|
+
const dlpSubject = corpus.length > prompt.length ? corpus : prompt;
|
|
1192
|
+
const dlpResult = scanDLP(dlpSubject, {
|
|
1193
|
+
entropyCheck: options?.entropyCheck,
|
|
1194
|
+
entropyMinBitsPerChar: options?.entropyMinBitsPerChar,
|
|
1195
|
+
entropyMinLength: options?.entropyMinLength,
|
|
1196
|
+
});
|
|
1197
|
+
const dlpTripped = config.enforceDLP && dlpResult.hasSensitiveData;
|
|
1198
|
+
// Entropy-only hits are medium confidence: always rerouted local (so they are
|
|
1199
|
+
// never transmitted) but never hard-blocked, because digests and base64 payloads
|
|
1200
|
+
// are legitimate content that merely looks random.
|
|
1201
|
+
const entropyOnly = dlpTripped && !dlpResult.highConfidence;
|
|
1202
|
+
// WHERE THIS WILL ACTUALLY LAND, decided before the role is applied and before anything is
|
|
1203
|
+
// transmitted. The reroute used to be committed here on entropy alone, which in the documented
|
|
1204
|
+
// cloud-only setup pinned the payload to the provider it was meant to be kept away from.
|
|
1205
|
+
// `classifyDestination` refuses instead when a reroute was requested but there is no distinct
|
|
1206
|
+
// local provider, or when the policy says block.
|
|
1207
|
+
const classification = (0, routing_1.classifyDestination)({
|
|
1208
|
+
provider: resolvedConfig?.provider || '',
|
|
1209
|
+
localProvider: config.localProvider,
|
|
1210
|
+
cloudProvider: config.cloudProvider,
|
|
1211
|
+
dlpTripped,
|
|
1212
|
+
entropyOnly,
|
|
1213
|
+
dlpAction: options?.dlpAction,
|
|
1214
|
+
});
|
|
1215
|
+
if (classification.decision === 'block') {
|
|
1216
|
+
// Name the pattern, not just the policy. An earlier throw said "credentials detected (API Key)",
|
|
1217
|
+
// and dropping that to a bare classification reason cost a test that asserts the message names
|
|
1218
|
+
// the offending pattern -- which is also what tells an operator what to remove.
|
|
1219
|
+
const violations = dlpResult.violations.join(', ');
|
|
1220
|
+
const why = violations
|
|
1221
|
+
? 'credentials detected (' + violations + '). ' + (classification.reason || '')
|
|
1222
|
+
: classification.reason || 'policy says block';
|
|
1223
|
+
console.error('[DLP_FIREWALL_BLOCK] Refusing to transmit: ' + why + '. Nothing was sent to the cloud.');
|
|
1224
|
+
throw new Error('DLP firewall blocked this request: ' + why +
|
|
1225
|
+
'. Nothing was transmitted. This gate scans every user message it has seen in this session, ' +
|
|
1226
|
+
"so a credential that appeared in an earlier turn keeps blocking until the session is restarted.");
|
|
1227
|
+
}
|
|
1228
|
+
const rerouteLocal = classification.rerouteLocal;
|
|
1229
|
+
if (dlpTripped) {
|
|
1230
|
+
const violations = dlpResult.violations.join(', ');
|
|
1231
|
+
(0, logging_1.trace)('DLP_FIREWALL_TRIPPED', {
|
|
1232
|
+
violations,
|
|
1233
|
+
action: rerouteLocal ? 'reroute-local' : 'block',
|
|
1234
|
+
confidence: dlpResult.highConfidence ? 'high' : 'entropy-only',
|
|
1235
|
+
prompt: prompt.slice(0, 100),
|
|
1236
|
+
});
|
|
1237
|
+
// The old shouldBlock branch stood here. It is dead: the classification above already threw for
|
|
1238
|
+
// the same two cases -- a reroute requested with no distinct local provider, and an explicit
|
|
1239
|
+
// block policy -- and it throws with a reason that names the actual cause.
|
|
1240
|
+
console.warn(`[DLP_FIREWALL_REROUTE] Sensitive data (${violations}) pinned to the LOCAL worker; it will not reach the WAN.`);
|
|
1241
|
+
}
|
|
1242
|
+
// Which role is this request? The plugin used to treat every agent as the architect, which is
|
|
1243
|
+
// right for the architect and wrong for everything else: a lead configured to run locally would
|
|
1244
|
+
// be repinned to the cloud and told it was the architect. The DLP gate above runs either way, so
|
|
1245
|
+
// opting a provider out of the architect role does not opt it out of the firewall.
|
|
1246
|
+
const role = (0, roles_1.resolveAgentRole)({
|
|
1247
|
+
hostProvider: resolvedConfig?.provider,
|
|
1248
|
+
leadProviders: (0, roles_1.resolveLeadProviders)(options),
|
|
1249
|
+
});
|
|
1250
|
+
// Correlate the role with the agent, so a later tool call can be attributed. Best effort by
|
|
1251
|
+
// construction: the host does not expose lineage, so this is the plugin's own inference.
|
|
1252
|
+
(0, roles_1.rememberAgentRole)(agent?.id, role.role);
|
|
1253
|
+
const mutatedConfig = (0, roles_1.applyAgentRole)(resolvedConfig || {}, role, {
|
|
1254
|
+
cloudProvider: config.cloudProvider,
|
|
1255
|
+
cloudModel: config.cloudModel,
|
|
1256
|
+
localProvider: config.localProvider,
|
|
1257
|
+
localModel: config.localModel,
|
|
1258
|
+
rerouteLocal,
|
|
1259
|
+
architectInstruction: profiles_1.PROFILES.ARCHITECT.systemInstruction,
|
|
1260
|
+
workerTool: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA,
|
|
1261
|
+
});
|
|
1262
|
+
// Rule 8: source may not reach the cloud. The read guard covers pulling delegated code back, and
|
|
1263
|
+
// contextFiles injects into the worker; this covers the blunt route — source sitting in the
|
|
1264
|
+
// outbound payload because it was typed into a cloud-bound conversation.
|
|
1265
|
+
const destination = rerouteLocal ||
|
|
1266
|
+
String(mutatedConfig.provider || '').toLowerCase() ===
|
|
1267
|
+
String(config.localProvider || '').toLowerCase()
|
|
1268
|
+
? 'local'
|
|
1269
|
+
: 'cloud';
|
|
1270
|
+
const egressDetection = (0, roles_1.detectSourceEgress)(dlpSubject, {
|
|
1271
|
+
minLines: options?.sourceEgressMinLines,
|
|
1272
|
+
});
|
|
1273
|
+
const egress = (0, roles_1.evaluateSourceEgress)(options?.sourceEgress ?? 'deny', egressDetection, destination);
|
|
1274
|
+
if (egress.kind !== 'allow') {
|
|
1275
|
+
let permitted = false;
|
|
1276
|
+
if (egress.kind === 'ask') {
|
|
1277
|
+
try {
|
|
1278
|
+
const approvalService = typeof ctx?.get === 'function' ? ctx.get('approval') : undefined;
|
|
1279
|
+
if (approvalService && typeof approvalService.request === 'function' && agent) {
|
|
1280
|
+
const outcome = await approvalService.request({
|
|
1281
|
+
agent,
|
|
1282
|
+
toolName: 'agent/request',
|
|
1283
|
+
reason: `This cloud-bound request carries source: ${egress.reason} Source is not supposed ` +
|
|
1284
|
+
`to reach the cloud (rule 8). Approve only if you mean to transmit it.`,
|
|
1285
|
+
...(payload?.signal ? { signal: payload.signal } : {}),
|
|
1286
|
+
});
|
|
1287
|
+
permitted = outcome === 'allowed-once';
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
catch (err) {
|
|
1291
|
+
console.warn('[SOURCE_EGRESS] approval request failed; failing closed:', err?.message || err);
|
|
1292
|
+
permitted = false;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (!permitted) {
|
|
1296
|
+
(0, logging_1.trace)('SOURCE_EGRESS_BLOCKED', {
|
|
1297
|
+
blocks: egressDetection.blocks,
|
|
1298
|
+
languages: egressDetection.languages,
|
|
1299
|
+
destination,
|
|
1300
|
+
action: options?.sourceEgress ?? 'deny',
|
|
1301
|
+
});
|
|
1302
|
+
throw new Error(`Source may not reach the cloud (rule 8): ${egress.reason} Nothing was transmitted. ` +
|
|
1303
|
+
`This gate reads every user message the session has sent, so a block from an earlier ` +
|
|
1304
|
+
`turn keeps it closed until the session is restarted.`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
(0, logging_1.trace)(role.role === 'lead'
|
|
1308
|
+
? 'HOOK_EXIT: LEAD_LEFT_AS_CONFIGURED (agent/request)'
|
|
1309
|
+
: rerouteLocal
|
|
1310
|
+
? 'HOOK_EXIT: DLP_PINNED_LOCAL (agent/request)'
|
|
1311
|
+
: 'HOOK_EXIT: ARCHITECT_CLOUD_PINNED (agent/request)', {
|
|
1312
|
+
role: role.role,
|
|
1313
|
+
roleReason: role.reason,
|
|
1314
|
+
provider: mutatedConfig.provider,
|
|
1315
|
+
model: mutatedConfig.model,
|
|
1316
|
+
uncappedContextWindow: role.role === 'architect',
|
|
1317
|
+
toolsCount: mutatedConfig.tools?.length || 0,
|
|
1318
|
+
});
|
|
1319
|
+
// The host expects `LlmCallConfig` here. `applyAgentRole` returns a looser record, so this is an
|
|
1320
|
+
// ASSERTION rather than a check: the plugin cannot prove it returns a valid config, and DSH does not
|
|
1321
|
+
// verify it either. Named rather than hidden -- the payload on the way in IS checked, which is where
|
|
1322
|
+
// the dead `payload.session` read was found.
|
|
1323
|
+
return mutatedConfig;
|
|
1324
|
+
}, { prepend: true });
|
|
1325
|
+
// Architect usage is deliberately NOT recorded in this ledger. Three subscriptions that tried to are
|
|
1326
|
+
// gone, because none of them could ever have fired -- checked against the installed host, not assumed:
|
|
1327
|
+
//
|
|
1328
|
+
// `agent/post-step` and `agent/step-finish` do not exist. Neither name appears anywhere in the
|
|
1329
|
+
// installed `@deepseek-ai/*` packages; this plugin invented them.
|
|
1330
|
+
//
|
|
1331
|
+
// `agent/assistant-stream` does exist, but its frames are `start` / `chunk` / `end`
|
|
1332
|
+
// (`AssistantStreamFrame` in `dsh-agent`) and none carries `usage`. That stream is "presentation data
|
|
1333
|
+
// rather than the replay source"; the durable settlement is `assistant/message`, which does carry
|
|
1334
|
+
// `usage?: TokenUsage`.
|
|
1335
|
+
//
|
|
1336
|
+
// So `cloudTurns`, `architectTurns`, `totalCloudTokens` and `totalSpendUSD` are permanently zero, and
|
|
1337
|
+
// the ledger's `scope` field now says so rather than leaving them to read as measured data.
|
|
1338
|
+
//
|
|
1339
|
+
// Reviving them was considered and rejected, not overlooked. It would mean a ledger write on every
|
|
1340
|
+
// architect model call -- 980 in one observed session -- to produce a dollar figure this plugin no
|
|
1341
|
+
// longer claims anything about. The architect side *is* measured, by the context-quality fold off the
|
|
1342
|
+
// same `session/event` subscription used above; it is measured as context, and it is not money.
|
|
1343
|
+
//
|
|
1344
|
+
// The general fix for this whole class -- a subscription to an event the host does not emit -- is to
|
|
1345
|
+
// type `ctx.on` against the host's `Events` interface so that such a name cannot compile. That is the
|
|
1346
|
+
// open half of the host contract: ROADMAP item 21 covers session event TYPES, not hook names.
|
|
1347
|
+
}
|
|
1348
|
+
// The patch engine is configured inside ./delegation.ts, where both halves now live.
|
|
1349
|
+
const pluginExport = {
|
|
1350
|
+
name: exports.name,
|
|
1351
|
+
inject: exports.inject,
|
|
1352
|
+
using: exports.using,
|
|
1353
|
+
apply,
|
|
1354
|
+
LocalRouter,
|
|
1355
|
+
SavingsTracker: savings_tracker_1.SavingsTracker,
|
|
1356
|
+
scanDLP,
|
|
1357
|
+
delegateWorker: delegation_1.delegateWorker,
|
|
1358
|
+
extractAndEmitFiles: emission_1.extractAndEmitFiles,
|
|
1359
|
+
runSandboxVerification: verification_1.runSandboxVerification,
|
|
1360
|
+
parseTestOutput: verification_1.parseTestOutput,
|
|
1361
|
+
sha256File: contracts_1.sha256File,
|
|
1362
|
+
resolveContextFiles: context_1.resolveContextFiles,
|
|
1363
|
+
parseSearchReplaceBlocks: delegation_1.parseSearchReplaceBlocks,
|
|
1364
|
+
applySearchReplaceBlocks: delegation_1.applySearchReplaceBlocks,
|
|
1365
|
+
MIN_SEARCH_CHARS: delegation_1.MIN_SEARCH_CHARS,
|
|
1366
|
+
DEFAULT_CONTEXT_MAX_BYTES: context_1.DEFAULT_CONTEXT_MAX_BYTES,
|
|
1367
|
+
resolveContractFiles: contracts_1.resolveContractFiles,
|
|
1368
|
+
contractFileHashes: contracts_1.contractFileHashes,
|
|
1369
|
+
contractViolations: contracts_1.contractViolations,
|
|
1370
|
+
resolveDelegateStatus: delegation_1.resolveDelegateStatus,
|
|
1371
|
+
resolveVerificationRepeats: delegation_1.resolveVerificationRepeats,
|
|
1372
|
+
MAX_VERIFICATION_REPEATS: delegation_1.MAX_VERIFICATION_REPEATS,
|
|
1373
|
+
resolveLeadProviders: roles_1.resolveLeadProviders,
|
|
1374
|
+
evaluateDelegatedReadPolicy: guard_1.evaluateDelegatedReadPolicy,
|
|
1375
|
+
rememberAgentRole: roles_1.rememberAgentRole,
|
|
1376
|
+
roleForAgent: roles_1.roleForAgent,
|
|
1377
|
+
resetAgentRoles: roles_1.resetAgentRoles,
|
|
1378
|
+
describeSourceRead: roles_1.describeSourceRead,
|
|
1379
|
+
AGENT_ROLE_LIMIT: roles_1.AGENT_ROLE_LIMIT,
|
|
1380
|
+
detectSourceEgress: roles_1.detectSourceEgress,
|
|
1381
|
+
evaluateSourceEgress: roles_1.evaluateSourceEgress,
|
|
1382
|
+
DEFAULT_SOURCE_EGRESS_MIN_LINES: roles_1.DEFAULT_SOURCE_EGRESS_MIN_LINES,
|
|
1383
|
+
resolveDelegatedRegistryPath: contracts_1.resolveDelegatedRegistryPath,
|
|
1384
|
+
parseDelegatedRegistry: contracts_1.parseDelegatedRegistry,
|
|
1385
|
+
mergeDelegatedRecords: contracts_1.mergeDelegatedRecords,
|
|
1386
|
+
pruneDelegatedRecords: contracts_1.pruneDelegatedRecords,
|
|
1387
|
+
saveDelegatedRegistry: contracts_1.saveDelegatedRegistry,
|
|
1388
|
+
loadDelegatedRegistry: contracts_1.loadDelegatedRegistry,
|
|
1389
|
+
rememberDelegated: contracts_1.rememberDelegated,
|
|
1390
|
+
resolveAgentRole: roles_1.resolveAgentRole,
|
|
1391
|
+
applyArchitectConfig: roles_1.applyArchitectConfig,
|
|
1392
|
+
applyAgentRole: roles_1.applyAgentRole,
|
|
1393
|
+
DELEGATE_WORKER_SCHEMA: delegation_1.DELEGATE_WORKER_SCHEMA,
|
|
1394
|
+
DELEGATE_WORKER_OPENAI_SCHEMA: delegation_1.DELEGATE_WORKER_OPENAI_SCHEMA,
|
|
1395
|
+
PROFILES: profiles_1.PROFILES,
|
|
1396
|
+
default: apply,
|
|
1397
|
+
};
|
|
1398
|
+
exports.default = pluginExport;
|