vgxness 1.8.0 → 1.9.1
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/dist/agents/canonical-agent-manifest.js +22 -19
- package/dist/agents/renderers/claude-renderer.js +3 -1
- package/dist/agents/renderers/opencode-renderer.js +2 -1
- package/dist/behavior/behavior-contract-manifest.js +42 -0
- package/dist/behavior/behavior-contract-schema.js +1 -0
- package/dist/behavior/behavior-contract-validation.js +42 -0
- package/dist/governance/index.js +1 -0
- package/dist/governance/risk-classifier.js +116 -0
- package/dist/mcp/control-plane-snapshot-service.js +272 -0
- package/dist/mcp/control-plane.js +2 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/schema.js +5 -0
- package/dist/mcp/validation.js +19 -1
- package/dist/orchestrator/natural-language-planner.js +34 -17
- package/dist/payload/context-budget-policy.js +17 -0
- package/dist/payload/context-budget-service.js +44 -0
- package/dist/permissions/policy-evaluator.js +88 -11
- package/dist/runs/execution-planning.js +1 -1
- package/dist/runs/run-service.js +129 -35
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import { SddWorkflowService } from '../sdd/sdd-workflow-service.js';
|
|
|
12
12
|
import { SkillRegistryService } from '../skills/skill-registry-service.js';
|
|
13
13
|
import { VerificationPlanService } from '../verification/index.js';
|
|
14
14
|
import { ProviderChangePlanService } from './provider-change-plan.js';
|
|
15
|
+
import { ContextCockpitSnapshotService } from './control-plane-snapshot-service.js';
|
|
15
16
|
import { ProviderDoctorService } from './provider-doctor.js';
|
|
16
17
|
import { ProviderStatusService } from './provider-status.js';
|
|
17
18
|
import { errorEnvelope, successEnvelope, } from './schema.js';
|
|
@@ -64,7 +65,7 @@ export function callVgxTool(call, services) {
|
|
|
64
65
|
case 'vgxness_session_restore':
|
|
65
66
|
return toEnvelope(validated.tool, services.memory.restoreSession(validated.input));
|
|
66
67
|
case 'vgxness_context_cockpit':
|
|
67
|
-
return toEnvelope(validated.tool, services.memory.
|
|
68
|
+
return toEnvelope(validated.tool, new ContextCockpitSnapshotService({ memory: services.memory, sdd: services.sdd }).build(validated.input));
|
|
68
69
|
case 'vgxness_agent_resolve':
|
|
69
70
|
return toEnvelope(validated.tool, services.agents.resolveAgents(validated.input));
|
|
70
71
|
case 'vgxness_agent_activate':
|
package/dist/mcp/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from './claude-code-scope.js';
|
|
|
13
13
|
export * from './claude-code-user-config.js';
|
|
14
14
|
export * from './claude-code-user-memory.js';
|
|
15
15
|
export * from './control-plane.js';
|
|
16
|
+
export * from './control-plane-snapshot-service.js';
|
|
16
17
|
export * from './doctor.js';
|
|
17
18
|
export * from './opencode-visibility.js';
|
|
18
19
|
export * from './opencode-handoff-preview.js';
|
package/dist/mcp/schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { permissionCategories } from '../permissions/schema.js';
|
|
3
3
|
import { sddPhases } from '../sdd/schema.js';
|
|
4
|
+
import { workflowIds } from '../workflows/schema.js';
|
|
4
5
|
import { verificationChangeTypes } from '../verification/index.js';
|
|
5
6
|
export const SUPPORTED_VGX_MCP_TOOL_NAMES = [
|
|
6
7
|
'vgxness_sdd_status',
|
|
@@ -98,6 +99,7 @@ const runStatuses = ['created', 'planned', 'running', 'needs-human', 'completed'
|
|
|
98
99
|
const finalRunStatuses = ['completed', 'failed', 'blocked', 'cancelled'];
|
|
99
100
|
const runOutcomes = ['success', 'partial', 'failure', 'blocked', 'cancelled'];
|
|
100
101
|
const payloadModes = ['compact', 'verbose'];
|
|
102
|
+
const contextCockpitLevels = ['compact', 'expanded', 'verbose'];
|
|
101
103
|
const providerChangePlanProviders = ['opencode', 'claude', 'antigravity', 'custom'];
|
|
102
104
|
const providerChangePlanTypes = ['opencode-mcp-install', 'claude-mcp-install', 'setup', 'install', 'config-preparation'];
|
|
103
105
|
const jsonValueSchema = z.lazy(() => z.union([z.string(), z.number().finite(), z.boolean(), z.null(), z.array(jsonValueSchema), z.record(z.string(), jsonValueSchema)]));
|
|
@@ -263,8 +265,10 @@ export const VGX_MCP_TOOL_INPUT_SCHEMAS = {
|
|
|
263
265
|
vgxness_context_cockpit: z
|
|
264
266
|
.object({
|
|
265
267
|
project: z.string().min(1),
|
|
268
|
+
change: z.string().min(1).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).optional(),
|
|
266
269
|
directory: z.string().min(1).optional(),
|
|
267
270
|
limit: z.number().int().min(1).max(100).optional(),
|
|
271
|
+
level: z.enum(contextCockpitLevels).optional(),
|
|
268
272
|
})
|
|
269
273
|
.passthrough(),
|
|
270
274
|
vgxness_agent_resolve: z
|
|
@@ -364,6 +368,7 @@ export const VGX_MCP_TOOL_INPUT_SCHEMAS = {
|
|
|
364
368
|
runId: z.string().min(1),
|
|
365
369
|
category: z.enum(permissionCategories),
|
|
366
370
|
operation: z.string().min(1),
|
|
371
|
+
workflow: z.enum(workflowIds).optional(),
|
|
367
372
|
phase: z.string().min(1).optional(),
|
|
368
373
|
agentId: z.string().min(1).optional(),
|
|
369
374
|
workspaceRoot: z.string().min(1).optional(),
|
package/dist/mcp/validation.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRiskyPermissionCategory, permissionCategories } from '../permissions/schema.js';
|
|
2
2
|
import { parseOperationRetryPolicy } from '../runs/operation-retry.js';
|
|
3
3
|
import { isSddPhase, sddPhases } from '../sdd/schema.js';
|
|
4
|
+
import { workflowIds } from '../workflows/schema.js';
|
|
4
5
|
import { supportedTypeMessage, verificationChangeTypes } from '../verification/index.js';
|
|
5
6
|
import { errorEnvelope, isVgxMcpToolName, } from './schema.js';
|
|
6
7
|
const scopes = ['project', 'personal'];
|
|
@@ -12,6 +13,7 @@ const finalRunStatuses = ['completed', 'failed', 'blocked', 'cancelled'];
|
|
|
12
13
|
const runOutcomes = ['success', 'partial', 'failure', 'blocked', 'cancelled'];
|
|
13
14
|
const permissionDecisions = ['allow', 'ask', 'deny'];
|
|
14
15
|
const payloadModes = ['compact', 'verbose'];
|
|
16
|
+
const contextCockpitLevels = ['compact', 'expanded', 'verbose'];
|
|
15
17
|
const providerChangePlanProviders = ['opencode', 'claude', 'antigravity', 'custom'];
|
|
16
18
|
const providerChangePlanTypes = ['opencode-mcp-install', 'claude-mcp-install', 'setup', 'install', 'config-preparation'];
|
|
17
19
|
const validChangePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -237,13 +239,18 @@ function validateSddCockpitInput(input, tool) {
|
|
|
237
239
|
return readProjectAndChange(record.value, tool);
|
|
238
240
|
}
|
|
239
241
|
function validateContextCockpitInput(input, tool) {
|
|
240
|
-
const record = inputRecord(input, tool, ['project', 'directory', 'limit']);
|
|
242
|
+
const record = inputRecord(input, tool, ['project', 'change', 'directory', 'limit', 'level']);
|
|
241
243
|
if (!record.ok)
|
|
242
244
|
return record;
|
|
243
245
|
const project = readNonEmptyString(record.value, 'project', tool);
|
|
244
246
|
if (!project.ok)
|
|
245
247
|
return project;
|
|
246
248
|
const result = { project: project.value };
|
|
249
|
+
const change = readOptionalChange(record.value, tool);
|
|
250
|
+
if (!change.ok)
|
|
251
|
+
return change;
|
|
252
|
+
if (change.value !== undefined)
|
|
253
|
+
result.change = change.value;
|
|
247
254
|
const directory = readOptionalNonEmptyString(record.value, 'directory', tool);
|
|
248
255
|
if (!directory.ok)
|
|
249
256
|
return directory;
|
|
@@ -254,6 +261,11 @@ function validateContextCockpitInput(input, tool) {
|
|
|
254
261
|
return validationFailure('limit must be an integer between 1 and 100', tool);
|
|
255
262
|
result.limit = record.value.limit;
|
|
256
263
|
}
|
|
264
|
+
const level = readOptionalOneOf(record.value, 'level', contextCockpitLevels, tool);
|
|
265
|
+
if (!level.ok)
|
|
266
|
+
return level;
|
|
267
|
+
if (level.value !== undefined)
|
|
268
|
+
result.level = level.value;
|
|
257
269
|
return { ok: true, value: result };
|
|
258
270
|
}
|
|
259
271
|
function readProjectAndChange(record, tool) {
|
|
@@ -822,6 +834,7 @@ function validateRunPreflightInput(input, tool) {
|
|
|
822
834
|
'runId',
|
|
823
835
|
'category',
|
|
824
836
|
'operation',
|
|
837
|
+
'workflow',
|
|
825
838
|
'phase',
|
|
826
839
|
'agentId',
|
|
827
840
|
'workspaceRoot',
|
|
@@ -846,6 +859,11 @@ function validateRunPreflightInput(input, tool) {
|
|
|
846
859
|
if (!operation.ok)
|
|
847
860
|
return operation;
|
|
848
861
|
const result = { runId: runId.value, category: category.value, operation: operation.value };
|
|
862
|
+
const workflow = readOptionalOneOf(record.value, 'workflow', workflowIds, tool);
|
|
863
|
+
if (!workflow.ok)
|
|
864
|
+
return workflow;
|
|
865
|
+
if (workflow.value !== undefined)
|
|
866
|
+
result.workflow = workflow.value;
|
|
849
867
|
const copied = copyOptionalStrings(result, record.value, tool, ['phase', 'agentId', 'workspaceRoot', 'targetPath', 'providerToolName']);
|
|
850
868
|
if (!copied.ok)
|
|
851
869
|
return copied;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { classifyIntentRisk } from '../governance/risk-classifier.js';
|
|
1
2
|
const stopWords = new Set(['a', 'an', 'the', 'to', 'for', 'of', 'and', 'or', 'with', 'we', 'before', 'new']);
|
|
2
3
|
const signalRules = [
|
|
3
4
|
{
|
|
4
5
|
signal: 'diagnostic-request',
|
|
5
6
|
terms: [
|
|
6
7
|
/\bdiagnos(e|tic|tics)\b/,
|
|
8
|
+
/\bdiagnosticar\b/,
|
|
7
9
|
/\bfail(ing|ure|ed)?\b/,
|
|
8
10
|
/\bcrash(es|ing)?\b/,
|
|
9
11
|
/\berrors?\b/,
|
|
@@ -18,17 +20,17 @@ const signalRules = [
|
|
|
18
20
|
{ signal: 'planning-request', terms: [/\bplan\b/, /\bpreview\b/, /\breview\b/, /\bdry[- ]run\b/] },
|
|
19
21
|
{
|
|
20
22
|
signal: 'answer-only',
|
|
21
|
-
terms: [/\bexplain\b/, /\bwhat\b/, /\bhow\b/, /\bwhy\b/, /\bdescribe\b/, /\bunderstand\b/, /\binvestigat(e|ion)\b/, /\binspect\b/, /\bread[- ]only\b/],
|
|
23
|
+
terms: [/\bexplain\b/, /\bexplicar\b/, /\bwhat\b/, /\bhow\b/, /\bwhy\b/, /\bdescribe\b/, /\bunderstand\b/, /\binvestigat(e|ion)\b/, /\binspect\b/, /\bread[- ]only\b/],
|
|
22
24
|
},
|
|
23
25
|
{ signal: 'new-capability', terms: [/\bbuild\b/, /\badd\b/, /\bcreate\b/, /\bnew\b/, /\bcapabilit(y|ies)\b/, /\bfeature\b/] },
|
|
24
26
|
{ signal: 'architecture-change', terms: [/\barchitecture\b/, /\barchitectural\b/, /\brefactor\b/, /\bboundar(y|ies)\b/] },
|
|
25
27
|
{ signal: 'workflow-change', terms: [/\bworkflow\b/, /\borchestrat(e|ion|or)\b/, /\bsdd\b/, /\bphase\b/] },
|
|
26
28
|
{ signal: 'persistence-change', terms: [/\bpersist(ent|ence)?\b/, /\bstorage\b/, /\bsqlite\b/, /\bdatabase\b/, /\bmemory\b/, /\bmigration\b/] },
|
|
27
|
-
{ signal: 'security-sensitive', terms: [/\bsecurity\b/, /\bauth\b/, /\btoken\b/, /\bsecret\b/, /\bpermission\b/] },
|
|
29
|
+
{ signal: 'security-sensitive', terms: [/\bsecurity\b/, /\bauth\b/, /\btoken\b/, /\bsecret\b/, /\bpermission\b/, /\bpermiso\b/, /\baprobaci[oó]n\b/] },
|
|
28
30
|
{ signal: 'broad-change', terms: [/\bmulti[- ]file\b/, /\bacross\b/, /\bend[- ]to[- ]end\b/, /\bsystem\b/] },
|
|
29
31
|
{ signal: 'execution-request', terms: [/\brun\b/, /\bexecute\b/, /\bapply\b/, /\bstart\b/, /\binstall\b/, /\bpush\b/] },
|
|
30
32
|
{ signal: 'provider-execution', terms: [/\bprovider\b/, /\bopencode\b/, /\bclaude\b/, /\bmodel\b/, /\bllm\b/] },
|
|
31
|
-
{ signal: 'file-edit-request', terms: [/\bedit\b/, /\bwrite\b/, /\bmodify\b/, /\bpatch\b/] },
|
|
33
|
+
{ signal: 'file-edit-request', terms: [/\bedit\b/, /\bwrite\b/, /\bmodify\b/, /\bpatch\b/, /\barreglar\b/] },
|
|
32
34
|
{ signal: 'provider-config-write', terms: [/\bconfig\b/, /\.opencode\b/, /\.claude\b/, /\bglobal\b/] },
|
|
33
35
|
{ signal: 'run-recording', terms: [/\brun record\b/, /\brecord a run\b/, /\bcheckpoint\b/] },
|
|
34
36
|
{ signal: 'destructive', terms: [/\bdelete\b/, /\bremove\b/, /\bdestroy\b/, /\bdrop\b/, /\breset\b/] },
|
|
@@ -41,7 +43,8 @@ export function createNaturalLanguagePlan(input) {
|
|
|
41
43
|
const ambiguous = isAmbiguous(normalizedIntent);
|
|
42
44
|
if (ambiguous)
|
|
43
45
|
addSignal(signals, 'ambiguous');
|
|
44
|
-
const
|
|
46
|
+
const risk = classifyIntentRisk({ project: input.project, intent: input.intent });
|
|
47
|
+
const flow = chooseFlow(signals, risk);
|
|
45
48
|
const workflow = workflowFor(flow);
|
|
46
49
|
const needsClarification = ambiguous;
|
|
47
50
|
const suggestedChangeId = suggestedChangeFor(input, flow);
|
|
@@ -67,13 +70,17 @@ export function createNaturalLanguagePlan(input) {
|
|
|
67
70
|
...(suggestedChangeId !== undefined ? { suggestedChangeId } : {}),
|
|
68
71
|
previewActions,
|
|
69
72
|
safety,
|
|
73
|
+
riskTier: risk.tier,
|
|
74
|
+
riskReasonCodes: risk.reasonCodes,
|
|
75
|
+
requiresSdd: risk.requiresSdd,
|
|
76
|
+
requiresExplicitValidation: risk.requiresExplicitValidation,
|
|
70
77
|
...(input.sdd !== undefined ? { sdd: input.sdd } : {}),
|
|
71
78
|
};
|
|
72
79
|
}
|
|
73
80
|
function normalizeText(value) {
|
|
74
81
|
return value
|
|
75
82
|
.toLowerCase()
|
|
76
|
-
.replace(/[^a-z0-9._/
|
|
83
|
+
.replace(/[^a-z0-9._/ áéíóúüñ-]+/g, ' ')
|
|
77
84
|
.replace(/\s+/g, ' ')
|
|
78
85
|
.trim();
|
|
79
86
|
}
|
|
@@ -118,23 +125,35 @@ function isAmbiguous(intent) {
|
|
|
118
125
|
return true;
|
|
119
126
|
return /^(fix|update|change|do|handle) (it|this|that)$/.test(intent);
|
|
120
127
|
}
|
|
121
|
-
function chooseFlow(signals) {
|
|
128
|
+
function chooseFlow(signals, risk) {
|
|
129
|
+
if (risk.requiresSdd)
|
|
130
|
+
return 'sdd';
|
|
131
|
+
if (risk.recommendedWorkflow === 'direct')
|
|
132
|
+
return 'explore';
|
|
133
|
+
if (risk.recommendedWorkflow === 'debug')
|
|
134
|
+
return 'debug';
|
|
135
|
+
if (risk.recommendedWorkflow === 'plan')
|
|
136
|
+
return 'plan';
|
|
137
|
+
if (risk.recommendedWorkflow === 'quickfix')
|
|
138
|
+
return 'quickfix';
|
|
139
|
+
if (risk.recommendedWorkflow === 'build')
|
|
140
|
+
return 'build';
|
|
122
141
|
if (signals.includes('answer-only') &&
|
|
123
142
|
!signals.includes('diagnostic-request') &&
|
|
124
143
|
!signals.includes('new-capability') &&
|
|
125
144
|
!signals.includes('planning-request') &&
|
|
126
145
|
!signals.some((signal) => unsafeSignals.has(signal)))
|
|
127
146
|
return 'explore';
|
|
128
|
-
if (
|
|
129
|
-
return '
|
|
147
|
+
if (risk.tier >= 3)
|
|
148
|
+
return signals.includes('diagnostic-request') ? 'debug' : signals.includes('planning-request') ? 'plan' : 'plan';
|
|
130
149
|
if (signals.includes('diagnostic-request'))
|
|
131
150
|
return 'debug';
|
|
132
151
|
if (signals.includes('answer-only') && !signals.includes('new-capability') && !signals.some((signal) => unsafeSignals.has(signal)))
|
|
133
152
|
return 'explore';
|
|
134
153
|
if (signals.includes('planning-request') && !signals.some((signal) => strongSddSignals.has(signal) || strongUnsafeSignals.has(signal)))
|
|
135
154
|
return 'plan';
|
|
136
|
-
if (signals.some((signal) =>
|
|
137
|
-
return '
|
|
155
|
+
if (signals.some((signal) => unsafeSignals.has(signal)))
|
|
156
|
+
return 'plan';
|
|
138
157
|
if (signals.includes('planning-request'))
|
|
139
158
|
return 'plan';
|
|
140
159
|
if (signals.includes('new-capability'))
|
|
@@ -171,7 +190,7 @@ function reasonFor(flow, signals, needsClarification) {
|
|
|
171
190
|
if (flow === 'debug' || flow === 'diagnose')
|
|
172
191
|
return 'The request asks for inspection or failure/status diagnosis, so the preview stays read-only.';
|
|
173
192
|
if (flow === 'sdd')
|
|
174
|
-
return 'The request
|
|
193
|
+
return 'The request changes governance, permission, SDD, architecture, or cross-surface safety semantics, so SDD is required.';
|
|
175
194
|
if (flow === 'plan')
|
|
176
195
|
return 'The request asks for planning or review without immediate mutation, so SDD is not required for this preview.';
|
|
177
196
|
if (flow === 'build')
|
|
@@ -198,14 +217,14 @@ function buildSafety(signals) {
|
|
|
198
217
|
'Preview only: this preview only classifies intent; no providers are called, no files are edited, no provider config is written, and no run is recorded.',
|
|
199
218
|
];
|
|
200
219
|
if (signals.includes('execution-request') || signals.includes('provider-execution') || signals.includes('file-edit-request')) {
|
|
201
|
-
notes.push('Execution was requested but refused in this preview flow; continue manually or through an approved
|
|
220
|
+
notes.push('Execution was requested but refused in this preview flow; continue manually or through an approved preflight/apply flow.');
|
|
202
221
|
}
|
|
203
222
|
if (signals.includes('destructive'))
|
|
204
223
|
notes.push('Destructive impact was detected; review and approval are required before any real operation.');
|
|
205
224
|
if (signals.includes('provider-config-write'))
|
|
206
225
|
notes.push('Provider configuration impact was detected; this preview will not write provider config.');
|
|
207
226
|
if (signals.includes('persistence-change'))
|
|
208
|
-
notes.push('Persistent behavior or storage impact was detected; SDD
|
|
227
|
+
notes.push('Persistent behavior or storage impact was detected; use the lightest workflow that matches risk tier and require SDD only for Tier 4.');
|
|
209
228
|
if (signals.includes('external'))
|
|
210
229
|
notes.push('External or network impact was detected; this preview does not perform external calls.');
|
|
211
230
|
if (signals.includes('privileged'))
|
|
@@ -225,13 +244,11 @@ function buildPreviewActions(input, flow, needsClarification) {
|
|
|
225
244
|
return [{ kind: 'workflow-preview', description: 'Preview a small localized quickfix; no execution occurs in this planner response.' }];
|
|
226
245
|
if (flow === 'build')
|
|
227
246
|
return [{ kind: 'workflow-preview', description: 'Preview a scoped build workflow; no execution occurs in this planner response.' }];
|
|
228
|
-
const change = input.change;
|
|
229
|
-
const command = change === undefined ? undefined : `vgxness sdd next --project ${input.project} --change ${change}`;
|
|
230
247
|
return [
|
|
231
248
|
{
|
|
232
249
|
kind: 'sdd-preview',
|
|
233
|
-
description: input.sdd?.next?.recommendedAction ??
|
|
234
|
-
|
|
250
|
+
description: input.sdd?.next?.recommendedAction ??
|
|
251
|
+
'Continue formal SDD progression through the provider-native manager flow and VGXNESS MCP; do not execute terminal SDD phase commands from this preview.',
|
|
235
252
|
},
|
|
236
253
|
];
|
|
237
254
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const defaultContextBudgetPolicy = {
|
|
2
|
+
policyVersion: '1.0.0',
|
|
3
|
+
limits: [
|
|
4
|
+
{ id: 'managerPromptBase', label: 'Manager prompt base', targetBytes: 16_384, hardLimitBytes: 24_576, enforcement: 'warn' },
|
|
5
|
+
{ id: 'managerInitialPayload', label: 'Manager initial payload', targetBytes: 12_288, hardLimitBytes: 18_432, enforcement: 'warn' },
|
|
6
|
+
{ id: 'snapshotCompact', label: 'Compact snapshot', targetBytes: 10_240, hardLimitBytes: 15_360, enforcement: 'warn' },
|
|
7
|
+
{ id: 'snapshotExpanded', label: 'Expanded snapshot', targetBytes: 30_720, hardLimitBytes: 46_080, enforcement: 'report' },
|
|
8
|
+
{ id: 'subagentVerbosePayload', label: 'Subagent verbose payload', targetBytes: 122_880, hardLimitBytes: 184_320, enforcement: 'report' },
|
|
9
|
+
{ id: 'initialMcpCalls', label: 'Initial MCP calls', targetBytes: 3, hardLimitBytes: 5, enforcement: 'report' },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
export function getContextBudgetLimit(policy, id) {
|
|
13
|
+
const limit = policy.limits.find((candidate) => candidate.id === id);
|
|
14
|
+
if (limit === undefined)
|
|
15
|
+
throw new Error(`Unknown context budget id: ${id}`);
|
|
16
|
+
return limit;
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { utf8ByteCount } from './payload-summary.js';
|
|
2
|
+
import { defaultContextBudgetPolicy, getContextBudgetLimit } from './context-budget-policy.js';
|
|
3
|
+
export class ContextBudgetService {
|
|
4
|
+
policy;
|
|
5
|
+
constructor(policy = defaultContextBudgetPolicy) {
|
|
6
|
+
this.policy = policy;
|
|
7
|
+
}
|
|
8
|
+
measureUtf8Bytes(content) {
|
|
9
|
+
return utf8ByteCount(content);
|
|
10
|
+
}
|
|
11
|
+
measureJsonBytes(value) {
|
|
12
|
+
return utf8ByteCount(JSON.stringify(value));
|
|
13
|
+
}
|
|
14
|
+
reportContent(id, content) {
|
|
15
|
+
return this.buildReport(id, this.measureUtf8Bytes(content));
|
|
16
|
+
}
|
|
17
|
+
reportJson(id, value) {
|
|
18
|
+
return this.buildReport(id, this.measureJsonBytes(value));
|
|
19
|
+
}
|
|
20
|
+
buildReport(id, measuredBytes) {
|
|
21
|
+
if (!Number.isSafeInteger(measuredBytes) || measuredBytes < 0)
|
|
22
|
+
throw new RangeError('measuredBytes must be a non-negative safe integer');
|
|
23
|
+
const budget = getContextBudgetLimit(this.policy, id);
|
|
24
|
+
const excessTargetBytes = Math.max(0, measuredBytes - budget.targetBytes);
|
|
25
|
+
const excessHardLimitBytes = Math.max(0, measuredBytes - budget.hardLimitBytes);
|
|
26
|
+
return {
|
|
27
|
+
policyVersion: this.policy.policyVersion,
|
|
28
|
+
budget,
|
|
29
|
+
measuredBytes,
|
|
30
|
+
withinTarget: excessTargetBytes === 0,
|
|
31
|
+
withinHardLimit: excessHardLimitBytes === 0,
|
|
32
|
+
excessTargetBytes,
|
|
33
|
+
excessHardLimitBytes,
|
|
34
|
+
recommendations: budgetRecommendations(budget, excessTargetBytes, excessHardLimitBytes),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function budgetRecommendations(budget, excessTargetBytes, excessHardLimitBytes) {
|
|
39
|
+
if (excessHardLimitBytes > 0)
|
|
40
|
+
return [`Reduce ${budget.label} below hard limit by at least ${excessHardLimitBytes} bytes.`, 'Move full content behind references or verbose-only retrieval.'];
|
|
41
|
+
if (excessTargetBytes > 0)
|
|
42
|
+
return [`Reduce ${budget.label} toward target by ${excessTargetBytes} bytes.`, 'Prefer compact summaries and progressive disclosure.'];
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
+
import { classifyOperationRisk } from '../governance/risk-classifier.js';
|
|
3
4
|
import { isSddPhase, sddPhases } from '../sdd/schema.js';
|
|
4
5
|
export { isRiskyPermissionCategory, permissionCategories, riskyPermissionCategories } from './schema.js';
|
|
5
6
|
export const permissionDecisions = ['allow', 'ask', 'deny'];
|
|
@@ -81,15 +82,24 @@ const filesystemCategories = new Set([
|
|
|
81
82
|
'external-directory',
|
|
82
83
|
]);
|
|
83
84
|
export function evaluatePermission(request, policy = defaultPermissionPolicy) {
|
|
85
|
+
const risk = classifyOperationRisk(request);
|
|
84
86
|
if (request.category === 'secrets')
|
|
85
|
-
return result(request, 'deny', 'secret_access', 'Secret access is denied by default.');
|
|
87
|
+
return result(request, 'deny', 'secret_access', 'Secret access is denied by default.', {}, risk);
|
|
86
88
|
if (request.category === 'external-directory') {
|
|
87
|
-
return result(request, 'deny', 'workspace_boundary', 'External directory access is denied by the foundation policy.');
|
|
89
|
+
return result(request, 'deny', 'workspace_boundary', 'External directory access is denied by the foundation policy.', {}, risk);
|
|
88
90
|
}
|
|
91
|
+
const hardRisk = hardRiskDecision(request, risk);
|
|
92
|
+
if (hardRisk !== undefined)
|
|
93
|
+
return hardRisk;
|
|
89
94
|
const workspaceEscape = workspaceEscapeDecision(request);
|
|
90
95
|
if (workspaceEscape !== undefined)
|
|
91
96
|
return workspaceEscape;
|
|
92
97
|
const sddPhaseDecision = phaseMatrixDecision(request);
|
|
98
|
+
if (sddPhaseDecision !== undefined && sddPhaseDecision.mode !== 'allow' && sddPhaseDecision.mode !== 'audit')
|
|
99
|
+
return sddPhaseDecision;
|
|
100
|
+
const explicitValidation = explicitValidationDecision(request, risk);
|
|
101
|
+
if (explicitValidation !== undefined)
|
|
102
|
+
return explicitValidation;
|
|
93
103
|
if (sddPhaseDecision !== undefined)
|
|
94
104
|
return sddPhaseDecision;
|
|
95
105
|
const boundary = workspaceBoundaryDecision(request);
|
|
@@ -102,11 +112,18 @@ export function evaluatePermission(request, policy = defaultPermissionPolicy) {
|
|
|
102
112
|
const baseMessage = agentDecision !== undefined
|
|
103
113
|
? `${request.agent?.mode ?? 'agent'} ${request.agent?.name ?? request.agent?.id ?? 'unknown'} sets ${request.category} to ${agentDecision}.`
|
|
104
114
|
: (baseRule?.reason ?? `No explicit rule exists for ${request.category}; ask by default.`);
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
return result(request,
|
|
115
|
+
const flagRisk = riskDecision(request);
|
|
116
|
+
if (flagRisk !== undefined && isLessRestrictive(baseDecision, flagRisk.decision)) {
|
|
117
|
+
return result(request, flagRisk.decision, flagRisk.reason, flagRisk.message, {}, risk);
|
|
118
|
+
}
|
|
119
|
+
if (risk.defaultDecision === 'audit' && baseDecision !== 'deny') {
|
|
120
|
+
return result(request, 'allow', risk.reasonCodes.includes('provider_readonly_audit') ? 'provider_readonly_audit' : risk.reasonCodes.includes('allowlisted_verification') ? 'allowlisted_command' : baseReason, baseMessage, {
|
|
121
|
+
mode: 'audit',
|
|
122
|
+
auditOnly: true,
|
|
123
|
+
warnings: risk.reasonCodes.includes('provider_readonly_audit') ? ['provider-readonly-audit-only'] : ['audit-only'],
|
|
124
|
+
}, risk);
|
|
108
125
|
}
|
|
109
|
-
return result(request, baseDecision, baseReason, baseMessage);
|
|
126
|
+
return result(request, baseDecision, baseReason, baseMessage, {}, risk);
|
|
110
127
|
}
|
|
111
128
|
export function isPathInsideWorkspace(workspaceRoot, targetPath) {
|
|
112
129
|
const root = resolve(workspaceRoot);
|
|
@@ -148,11 +165,27 @@ function canonicalExistingPath(targetPath) {
|
|
|
148
165
|
function workspaceBoundaryDecision(request) {
|
|
149
166
|
if (!filesystemCategories.has(request.category))
|
|
150
167
|
return undefined;
|
|
168
|
+
if (request.category === 'read' && request.workspaceRoot !== undefined && request.targetPath === undefined) {
|
|
169
|
+
return result(request, 'allow', 'workspace_boundary', 'Broad read-only workspace access is allowed when workspaceRoot is known.', {
|
|
170
|
+
mode: 'audit',
|
|
171
|
+
auditOnly: true,
|
|
172
|
+
boundary: { workspaceRoot: request.workspaceRoot, insideWorkspace: true, targetPathRequired: false },
|
|
173
|
+
warnings: ['broad-workspace-read'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
151
176
|
if (request.workspaceRoot === undefined || request.targetPath === undefined) {
|
|
152
|
-
return result(request, 'ask', 'ambiguous_operation', 'Filesystem
|
|
177
|
+
return result(request, 'ask', 'ambiguous_operation', 'Filesystem mutation needs workspaceRoot and targetPath before it can be safely decided.', {
|
|
178
|
+
boundary: {
|
|
179
|
+
...(request.workspaceRoot === undefined ? {} : { workspaceRoot: request.workspaceRoot }),
|
|
180
|
+
...(request.targetPath === undefined ? {} : { targetPath: request.targetPath }),
|
|
181
|
+
targetPathRequired: request.category !== 'read',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
153
184
|
}
|
|
154
185
|
if (!isPathInsideWorkspace(request.workspaceRoot, request.targetPath)) {
|
|
155
|
-
return result(request, 'deny', 'workspace_boundary', `Path escapes workspace boundary: ${request.targetPath}
|
|
186
|
+
return result(request, 'deny', 'workspace_boundary', `Path escapes workspace boundary: ${request.targetPath}`, {
|
|
187
|
+
boundary: { workspaceRoot: request.workspaceRoot, targetPath: request.targetPath, insideWorkspace: false, targetPathRequired: true },
|
|
188
|
+
});
|
|
156
189
|
}
|
|
157
190
|
return undefined;
|
|
158
191
|
}
|
|
@@ -162,7 +195,9 @@ function workspaceEscapeDecision(request) {
|
|
|
162
195
|
if (request.workspaceRoot === undefined || request.targetPath === undefined)
|
|
163
196
|
return undefined;
|
|
164
197
|
if (!isPathInsideWorkspace(request.workspaceRoot, request.targetPath)) {
|
|
165
|
-
return result(request, 'deny', 'workspace_boundary', `Path escapes workspace boundary: ${request.targetPath}
|
|
198
|
+
return result(request, 'deny', 'workspace_boundary', `Path escapes workspace boundary: ${request.targetPath}`, {
|
|
199
|
+
boundary: { workspaceRoot: request.workspaceRoot, targetPath: request.targetPath, insideWorkspace: false, targetPathRequired: true },
|
|
200
|
+
});
|
|
166
201
|
}
|
|
167
202
|
return undefined;
|
|
168
203
|
}
|
|
@@ -212,6 +247,37 @@ function riskDecision(request) {
|
|
|
212
247
|
return { decision: 'ask', reason: 'ambiguous_operation', message: 'Ambiguous operations require human approval.' };
|
|
213
248
|
return undefined;
|
|
214
249
|
}
|
|
250
|
+
function hardRiskDecision(request, risk) {
|
|
251
|
+
if (request.category === 'shell' && /\b(arbitrary|shell-string)\b|[;&|`$()]/.test(request.operation))
|
|
252
|
+
return result(request, 'deny', 'risk_tier_policy', 'Arbitrary shell execution is denied unless represented as an allowlisted category.', {}, risk);
|
|
253
|
+
if (risk.defaultDecision === 'deny')
|
|
254
|
+
return result(request, 'deny', 'risk_tier_policy', 'Risk classifier denies this operation by default.', {}, risk);
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
function explicitValidationDecision(request, risk) {
|
|
258
|
+
if (!risk.requiresExplicitValidation)
|
|
259
|
+
return undefined;
|
|
260
|
+
if (request.category === 'secrets')
|
|
261
|
+
return undefined;
|
|
262
|
+
return result(request, 'ask', reasonForRisk(request, risk), 'Risk tier policy requires explicit validation before this operation.', {}, risk);
|
|
263
|
+
}
|
|
264
|
+
function reasonForRisk(request, risk) {
|
|
265
|
+
if (risk.reasonCodes.includes('provider_config_write'))
|
|
266
|
+
return 'risk_tier_policy';
|
|
267
|
+
if (risk.reasonCodes.includes('git_write'))
|
|
268
|
+
return 'risk_tier_policy';
|
|
269
|
+
if (risk.reasonCodes.includes('publish_release'))
|
|
270
|
+
return 'risk_tier_policy';
|
|
271
|
+
if (request.destructive === true)
|
|
272
|
+
return 'destructive_operation';
|
|
273
|
+
if (request.external === true)
|
|
274
|
+
return 'external_operation';
|
|
275
|
+
if (request.privileged === true)
|
|
276
|
+
return 'privileged_operation';
|
|
277
|
+
if (request.ambiguous === true)
|
|
278
|
+
return 'ambiguous_operation';
|
|
279
|
+
return 'risk_tier_policy';
|
|
280
|
+
}
|
|
215
281
|
function isLessRestrictive(candidate, floor) {
|
|
216
282
|
return rank(candidate) < rank(floor);
|
|
217
283
|
}
|
|
@@ -222,6 +288,17 @@ function rank(decision) {
|
|
|
222
288
|
return 1;
|
|
223
289
|
return 2;
|
|
224
290
|
}
|
|
225
|
-
function result(request, decision, reason, message, extras = {}) {
|
|
226
|
-
return {
|
|
291
|
+
function result(request, decision, reason, message, extras = {}, risk = classifyOperationRisk(request)) {
|
|
292
|
+
return {
|
|
293
|
+
decision,
|
|
294
|
+
category: request.category,
|
|
295
|
+
operation: request.operation,
|
|
296
|
+
reason,
|
|
297
|
+
message,
|
|
298
|
+
riskTier: risk.tier,
|
|
299
|
+
riskReasonCodes: risk.reasonCodes,
|
|
300
|
+
...(request.workflow === undefined ? {} : { workflow: request.workflow }),
|
|
301
|
+
auditEvidence: risk.evidence,
|
|
302
|
+
...extras,
|
|
303
|
+
};
|
|
227
304
|
}
|
|
@@ -107,5 +107,5 @@ const defaultGitBoundaryInspector = ({ workspaceRoot }) => {
|
|
|
107
107
|
if (existsSync(join(workspaceRoot, '.git'))) {
|
|
108
108
|
return { status: 'compatible', reason: 'workspace git metadata is present' };
|
|
109
109
|
}
|
|
110
|
-
return { status: '
|
|
110
|
+
return { status: 'compatible', reason: 'workspace has no git metadata to conflict with a planned worktree boundary' };
|
|
111
111
|
};
|