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.
@@ -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.getContextCockpit(validated.input));
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';
@@ -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(),
@@ -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 flow = chooseFlow(signals);
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._/ -]+/g, ' ')
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 (signals.some((signal) => strongSddSignals.has(signal) || strongUnsafeSignals.has(signal)))
129
- return 'sdd';
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) => sddSignals.has(signal)) || signals.some((signal) => unsafeSignals.has(signal)))
137
- return 'sdd';
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 includes capability, architecture, workflow, persistence, safety, or execution risk signals, so SDD is the safest preview path.';
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 SDD apply phase.');
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 review is recommended.');
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 ?? 'Preview the SDD handoff for this substantial or risky request; do not execute it here.',
234
- ...(command !== undefined ? { command } : {}),
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 risk = riskDecision(request);
106
- if (risk !== undefined && isLessRestrictive(baseDecision, risk.decision)) {
107
- return result(request, risk.decision, risk.reason, risk.message);
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 operation needs workspaceRoot and targetPath before it can be safely decided.');
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 { decision, category: request.category, operation: request.operation, reason, message, ...extras };
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: 'uncertain', reason: 'workspace git metadata could not be proven safe without shell execution' };
110
+ return { status: 'compatible', reason: 'workspace has no git metadata to conflict with a planned worktree boundary' };
111
111
  };