agentxchain 2.44.0 → 2.46.0
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/README.md +9 -8
- package/package.json +1 -1
- package/src/commands/accept-turn.js +30 -0
- package/src/commands/init.js +7 -1
- package/src/lib/adapters/api-proxy-adapter.js +53 -5
- package/src/lib/blocked-state.js +24 -0
- package/src/lib/governed-state.js +157 -0
- package/src/lib/governed-templates.js +2 -0
- package/src/lib/normalized-config.js +14 -2
- package/src/lib/policy-evaluator.js +330 -0
- package/src/templates/governed/enterprise-app.json +20 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Legacy IDE-window coordination is still shipped as a compatibility mode for team
|
|
|
24
24
|
See governance before you scaffold a real repo:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
npx agentxchain demo
|
|
27
|
+
npx --yes -p agentxchain@latest -c "agentxchain demo"
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Requires Node.js 18.17+ or 20.5+ and `git`. The demo creates a temporary governed repo, runs a full PM -> Dev -> QA lifecycle through the real runner interface, shows gates/decisions/objections, and removes the temp workspace when finished. No API keys, config edits, or manual turn authoring required.
|
|
@@ -33,12 +33,13 @@ Requires Node.js 18.17+ or 20.5+ and `git`. The demo creates a temporary governe
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
npm install -g agentxchain
|
|
36
|
+
agentxchain --version
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
For a zero-install one-off command, use the package-bound form:
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
npx
|
|
42
|
+
npx --yes -p agentxchain@latest -c "agentxchain demo"
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
## Testing
|
|
@@ -62,7 +63,7 @@ Duplicate execution remains intentional for the current 36-file slice until a la
|
|
|
62
63
|
### Governed workflow
|
|
63
64
|
|
|
64
65
|
```bash
|
|
65
|
-
|
|
66
|
+
agentxchain init --governed --dir my-agentxchain-project -y
|
|
66
67
|
cd my-agentxchain-project
|
|
67
68
|
git init
|
|
68
69
|
git add -A
|
|
@@ -74,13 +75,13 @@ agentxchain step --role pm
|
|
|
74
75
|
The default governed dev runtime is `claude --print --dangerously-skip-permissions` with stdin prompt delivery. The non-interactive governed path needs write access, so do not pretend bare `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
|
|
75
76
|
|
|
76
77
|
```bash
|
|
77
|
-
|
|
78
|
+
agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
|
|
78
79
|
```
|
|
79
80
|
|
|
80
81
|
If you want template-specific planning artifacts from day one:
|
|
81
82
|
|
|
82
83
|
```bash
|
|
83
|
-
|
|
84
|
+
agentxchain init --governed --template api-service --dir my-agentxchain-project -y
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
Built-in governed templates:
|
|
@@ -119,8 +120,8 @@ Default governed scaffolding configures QA as `api_proxy` with `ANTHROPIC_API_KE
|
|
|
119
120
|
For initiatives spanning multiple governed repos, use the coordinator to add cross-repo sequencing and shared gates:
|
|
120
121
|
|
|
121
122
|
```bash
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
agentxchain init --governed --template api-service --dir repos/backend -y
|
|
124
|
+
agentxchain init --governed --template web-app --dir repos/frontend -y
|
|
124
125
|
agentxchain multi init
|
|
125
126
|
agentxchain multi step --json
|
|
126
127
|
```
|
package/package.json
CHANGED
|
@@ -23,6 +23,36 @@ export async function acceptTurnCommand(opts = {}) {
|
|
|
23
23
|
resolutionMode: opts.resolution || 'standard',
|
|
24
24
|
});
|
|
25
25
|
if (!result.ok) {
|
|
26
|
+
if (result.error_code === 'policy_escalation' || result.error_code === 'policy_violation') {
|
|
27
|
+
const recovery = result.state ? deriveRecoveryDescriptor(result.state, config) : null;
|
|
28
|
+
const retainedTurnId = result.state?.blocked_reason?.turn_id || opts.turn || '(unknown)';
|
|
29
|
+
const policyTitle = result.error_code === 'policy_escalation'
|
|
30
|
+
? 'Turn Acceptance Escalated By Policy'
|
|
31
|
+
: 'Turn Acceptance Blocked By Policy';
|
|
32
|
+
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log(chalk.yellow(` ${policyTitle}`));
|
|
35
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(` ${chalk.dim('Turn:')} ${retainedTurnId}`);
|
|
38
|
+
console.log(` ${chalk.dim('Error:')} ${result.error}`);
|
|
39
|
+
const violations = Array.isArray(result.policy_violations) ? result.policy_violations : [];
|
|
40
|
+
for (const violation of violations) {
|
|
41
|
+
console.log(` ${chalk.dim('Policy:')} ${violation.policy_id} (${violation.rule})`);
|
|
42
|
+
console.log(` ${chalk.dim('Detail:')} ${violation.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (recovery) {
|
|
45
|
+
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
46
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
47
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
48
|
+
console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(` ${chalk.dim('Action:')} Fix the policy condition, then rerun agentxchain accept-turn`);
|
|
51
|
+
}
|
|
52
|
+
console.log('');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
|
|
27
57
|
const recovery = deriveRecoveryDescriptor(result.state);
|
|
28
58
|
const activeTurn = result.state?.current_turn;
|
package/src/commands/init.js
CHANGED
|
@@ -547,11 +547,14 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
|
|
|
547
547
|
Object.keys(roles).map((roleId) => [roleId, `.agentxchain/prompts/${roleId}.md`])
|
|
548
548
|
);
|
|
549
549
|
|
|
550
|
+
const policies = cloneJsonCompatible(blueprint?.policies || []);
|
|
551
|
+
|
|
550
552
|
return {
|
|
551
553
|
roles,
|
|
552
554
|
runtimes,
|
|
553
555
|
routing,
|
|
554
556
|
gates,
|
|
557
|
+
policies,
|
|
555
558
|
prompts,
|
|
556
559
|
workflowKitConfig: effectiveWorkflowKitConfig,
|
|
557
560
|
};
|
|
@@ -627,7 +630,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
|
|
|
627
630
|
const template = loadGovernedTemplate(templateId);
|
|
628
631
|
const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
|
|
629
632
|
const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig);
|
|
630
|
-
const { roles, runtimes, routing, gates, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
|
|
633
|
+
const { roles, runtimes, routing, gates, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
|
|
631
634
|
const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
|
|
632
635
|
? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
|
|
633
636
|
: null;
|
|
@@ -667,6 +670,9 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
|
|
|
667
670
|
max_deadlock_cycles: 2
|
|
668
671
|
}
|
|
669
672
|
};
|
|
673
|
+
if (policies && policies.length > 0) {
|
|
674
|
+
config.policies = policies;
|
|
675
|
+
}
|
|
670
676
|
if (effectiveWorkflowKitConfig) {
|
|
671
677
|
config.workflow_kit = effectiveWorkflowKitConfig;
|
|
672
678
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* All error returns include a `classified` ApiProxyError object with
|
|
24
24
|
* error_class, recovery instructions, and retryable flag.
|
|
25
25
|
*
|
|
26
|
-
* Supported providers: "anthropic", "openai", "google"
|
|
26
|
+
* Supported providers: "anthropic", "openai", "google", "ollama"
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
|
|
@@ -51,6 +51,7 @@ const PROVIDER_ENDPOINTS = {
|
|
|
51
51
|
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
52
52
|
openai: 'https://api.openai.com/v1/chat/completions',
|
|
53
53
|
google: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
|
54
|
+
ollama: 'http://localhost:11434/v1/chat/completions',
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
// Bundled cost rates per million tokens (USD).
|
|
@@ -161,6 +162,22 @@ const PROVIDER_ERROR_MAPS = {
|
|
|
161
162
|
{ provider_error_type: 'INTERNAL', http_status: 500, error_class: 'unknown_api_error', retryable: true },
|
|
162
163
|
],
|
|
163
164
|
},
|
|
165
|
+
// Ollama uses OpenAI-compatible error structure
|
|
166
|
+
ollama: {
|
|
167
|
+
extractErrorType(body) {
|
|
168
|
+
return typeof body?.error?.type === 'string' ? body.error.type : null;
|
|
169
|
+
},
|
|
170
|
+
extractErrorCode(body) {
|
|
171
|
+
return typeof body?.error?.code === 'string' ? body.error.code : null;
|
|
172
|
+
},
|
|
173
|
+
mappings: [
|
|
174
|
+
{ provider_error_code: 'invalid_api_key', http_status: 401, error_class: 'auth_failure', retryable: false },
|
|
175
|
+
{ provider_error_code: 'model_not_found', http_status: 404, error_class: 'model_not_found', retryable: false },
|
|
176
|
+
{ provider_error_type: 'invalid_request_error', http_status: 400, body_pattern: /context|token.*limit|too.many.tokens/i, error_class: 'context_overflow', retryable: false },
|
|
177
|
+
{ provider_error_type: 'invalid_request_error', http_status: 400, error_class: 'invalid_request', retryable: false },
|
|
178
|
+
{ provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
164
181
|
};
|
|
165
182
|
|
|
166
183
|
// ── Error classification ──────────────────────────────────────────────────────
|
|
@@ -465,7 +482,7 @@ function usageFromTelemetry(provider, model, usage, config) {
|
|
|
465
482
|
let inputTokens = 0;
|
|
466
483
|
let outputTokens = 0;
|
|
467
484
|
|
|
468
|
-
if (provider === 'openai') {
|
|
485
|
+
if (provider === 'openai' || provider === 'ollama') {
|
|
469
486
|
inputTokens = Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : 0;
|
|
470
487
|
outputTokens = Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : 0;
|
|
471
488
|
} else if (provider === 'google') {
|
|
@@ -811,9 +828,10 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
|
|
|
811
828
|
const provider = runtime.provider;
|
|
812
829
|
const model = runtime.model;
|
|
813
830
|
const authEnv = runtime.auth_env;
|
|
814
|
-
const apiKey = process.env[authEnv];
|
|
831
|
+
const apiKey = authEnv ? process.env[authEnv] : null;
|
|
815
832
|
|
|
816
|
-
|
|
833
|
+
// Auth is required for cloud providers, optional for local providers (ollama)
|
|
834
|
+
if (!apiKey && authEnv) {
|
|
817
835
|
const classified = classifyError(
|
|
818
836
|
'missing_credentials',
|
|
819
837
|
`Environment variable "${authEnv}" is not set — required for api_proxy`,
|
|
@@ -1124,6 +1142,15 @@ function buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens) {
|
|
|
1124
1142
|
};
|
|
1125
1143
|
}
|
|
1126
1144
|
|
|
1145
|
+
function buildOllamaRequest(promptMd, contextMd, model, maxOutputTokens) {
|
|
1146
|
+
const openAiCompatible = buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1147
|
+
return {
|
|
1148
|
+
...openAiCompatible,
|
|
1149
|
+
max_tokens: maxOutputTokens,
|
|
1150
|
+
max_completion_tokens: undefined,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1127
1154
|
function buildGoogleHeaders(_apiKey) {
|
|
1128
1155
|
// Google Gemini uses API key as a query parameter, not a header
|
|
1129
1156
|
return {
|
|
@@ -1203,6 +1230,14 @@ function extractGoogleTurnResult(responseData) {
|
|
|
1203
1230
|
return extraction;
|
|
1204
1231
|
}
|
|
1205
1232
|
|
|
1233
|
+
function buildOllamaHeaders(apiKey) {
|
|
1234
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1235
|
+
if (apiKey) {
|
|
1236
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
1237
|
+
}
|
|
1238
|
+
return headers;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1206
1241
|
function buildProviderHeaders(provider, apiKey) {
|
|
1207
1242
|
if (provider === 'openai') {
|
|
1208
1243
|
return buildOpenAiHeaders(apiKey);
|
|
@@ -1210,6 +1245,9 @@ function buildProviderHeaders(provider, apiKey) {
|
|
|
1210
1245
|
if (provider === 'google') {
|
|
1211
1246
|
return buildGoogleHeaders(apiKey);
|
|
1212
1247
|
}
|
|
1248
|
+
if (provider === 'ollama') {
|
|
1249
|
+
return buildOllamaHeaders(apiKey);
|
|
1250
|
+
}
|
|
1213
1251
|
return buildAnthropicHeaders(apiKey);
|
|
1214
1252
|
}
|
|
1215
1253
|
|
|
@@ -1217,6 +1255,9 @@ function buildProviderRequest(provider, promptMd, contextMd, model, maxOutputTok
|
|
|
1217
1255
|
if (provider === 'openai') {
|
|
1218
1256
|
return buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1219
1257
|
}
|
|
1258
|
+
if (provider === 'ollama') {
|
|
1259
|
+
return buildOllamaRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1260
|
+
}
|
|
1220
1261
|
if (provider === 'google') {
|
|
1221
1262
|
return buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens);
|
|
1222
1263
|
}
|
|
@@ -1304,7 +1345,7 @@ function extractOpenAiTurnResult(responseData) {
|
|
|
1304
1345
|
}
|
|
1305
1346
|
|
|
1306
1347
|
function extractTurnResult(responseData, provider = 'anthropic') {
|
|
1307
|
-
if (provider === 'openai') {
|
|
1348
|
+
if (provider === 'openai' || provider === 'ollama') {
|
|
1308
1349
|
return extractOpenAiTurnResult(responseData);
|
|
1309
1350
|
}
|
|
1310
1351
|
if (provider === 'google') {
|
|
@@ -1324,10 +1365,17 @@ export {
|
|
|
1324
1365
|
extractTurnResult,
|
|
1325
1366
|
buildAnthropicRequest,
|
|
1326
1367
|
buildOpenAiRequest,
|
|
1368
|
+
buildOllamaRequest,
|
|
1327
1369
|
buildGoogleRequest,
|
|
1370
|
+
buildOllamaHeaders,
|
|
1371
|
+
buildProviderHeaders,
|
|
1372
|
+
buildProviderRequest,
|
|
1328
1373
|
classifyError,
|
|
1329
1374
|
classifyHttpError,
|
|
1375
|
+
DEFAULT_RETRY_POLICY,
|
|
1330
1376
|
BUNDLED_COST_RATES,
|
|
1331
1377
|
BUNDLED_COST_RATES as COST_RATES, // backward compat alias
|
|
1332
1378
|
getCostRates,
|
|
1379
|
+
PROVIDER_ENDPOINTS,
|
|
1380
|
+
RETRYABLE_ERROR_CLASSES,
|
|
1333
1381
|
};
|
package/src/lib/blocked-state.js
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
deriveEscalationRecoveryAction,
|
|
5
5
|
deriveHookTamperRecoveryAction,
|
|
6
6
|
deriveNeedsHumanRecoveryAction,
|
|
7
|
+
derivePolicyEscalationDetail,
|
|
8
|
+
derivePolicyEscalationRecoveryAction,
|
|
7
9
|
getActiveTurnCount,
|
|
8
10
|
} from './governed-state.js';
|
|
9
11
|
|
|
@@ -53,6 +55,13 @@ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetain
|
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
if (typedReason === 'policy_escalation') {
|
|
59
|
+
return derivePolicyEscalationRecoveryAction(state, config, {
|
|
60
|
+
turnRetained,
|
|
61
|
+
turnId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
|
|
57
66
|
return deriveConflictLoopRecoveryAction(turnId);
|
|
58
67
|
}
|
|
@@ -158,6 +167,21 @@ export function deriveRecoveryDescriptor(state, config = null) {
|
|
|
158
167
|
};
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
if (state.blocked_on.startsWith('policy:')) {
|
|
171
|
+
const policyId = state.blocked_on.slice('policy:'.length).trim() || null;
|
|
172
|
+
return {
|
|
173
|
+
typed_reason: 'policy_escalation',
|
|
174
|
+
owner: 'human',
|
|
175
|
+
recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
|
|
176
|
+
turnRetained,
|
|
177
|
+
turnId: state.blocked_reason?.turn_id ?? null,
|
|
178
|
+
policyId,
|
|
179
|
+
}),
|
|
180
|
+
turn_retained: turnRetained,
|
|
181
|
+
detail: derivePolicyEscalationDetail(state, { policyId }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
return {
|
|
162
186
|
typed_reason: 'unknown_block',
|
|
163
187
|
owner: 'human',
|
|
@@ -21,6 +21,7 @@ import { randomBytes, createHash } from 'crypto';
|
|
|
21
21
|
import { safeWriteJson } from './safe-write.js';
|
|
22
22
|
import { validateStagedTurnResult } from './turn-result-validator.js';
|
|
23
23
|
import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
|
|
24
|
+
import { evaluatePolicies } from './policy-evaluator.js';
|
|
24
25
|
import {
|
|
25
26
|
captureBaseline,
|
|
26
27
|
observeChanges,
|
|
@@ -350,6 +351,58 @@ export function deriveDispatchRecoveryAction(state, config, options = {}) {
|
|
|
350
351
|
return `Resolve the dispatch issue, then run ${command}`;
|
|
351
352
|
}
|
|
352
353
|
|
|
354
|
+
function normalizePolicyId(policyId) {
|
|
355
|
+
if (typeof policyId !== 'string') {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const trimmed = policyId.trim();
|
|
359
|
+
return trimmed || null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getPolicyIdFromBlockedState(state) {
|
|
363
|
+
if (typeof state?.blocked_on !== 'string' || !state.blocked_on.startsWith('policy:')) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
return normalizePolicyId(state.blocked_on.slice('policy:'.length));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function derivePolicyEscalationDetail(state, options = {}) {
|
|
370
|
+
if (typeof options.detail === 'string' && options.detail.trim()) {
|
|
371
|
+
return options.detail.trim();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (typeof state?.blocked_reason === 'string' && state.blocked_reason.trim()) {
|
|
375
|
+
return state.blocked_reason.trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
|
|
379
|
+
return policyId ? `Policy "${policyId}" triggered` : (state?.blocked_on || 'Policy escalation');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function derivePolicyEscalationRecoveryAction(state, config, options = {}) {
|
|
383
|
+
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
384
|
+
turnRetained: options.turnRetained,
|
|
385
|
+
turnId: options.turnId,
|
|
386
|
+
});
|
|
387
|
+
const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
|
|
388
|
+
return policyId
|
|
389
|
+
? `Resolve policy "${policyId}" condition, then run ${command}`
|
|
390
|
+
: `Resolve the policy condition, then run ${command}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function readTurnCostUsd(turnResult) {
|
|
394
|
+
if (!turnResult || typeof turnResult !== 'object') {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
if (typeof turnResult.cost?.usd === 'number') {
|
|
398
|
+
return turnResult.cost.usd;
|
|
399
|
+
}
|
|
400
|
+
if (typeof turnResult.cost?.total_usd === 'number') {
|
|
401
|
+
return turnResult.cost.total_usd;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
353
406
|
export function deriveHookTamperRecoveryAction(state, config, options = {}) {
|
|
354
407
|
const command = deriveBlockedRecoveryCommand(state, config, {
|
|
355
408
|
turnRetained: options.turnRetained,
|
|
@@ -1149,6 +1202,13 @@ export function reconcileRecoveryActionsWithConfig(state, config) {
|
|
|
1149
1202
|
turnId,
|
|
1150
1203
|
});
|
|
1151
1204
|
}
|
|
1205
|
+
} else if (typedReason === 'policy_escalation') {
|
|
1206
|
+
nextAction = derivePolicyEscalationRecoveryAction(state, config, {
|
|
1207
|
+
turnRetained,
|
|
1208
|
+
turnId,
|
|
1209
|
+
policyId: getPolicyIdFromBlockedState(state),
|
|
1210
|
+
});
|
|
1211
|
+
shouldRefresh = currentAction !== nextAction;
|
|
1152
1212
|
} else if (typedReason === 'conflict_loop') {
|
|
1153
1213
|
shouldRefresh = isLegacyConflictLoopRecoveryAction(currentAction);
|
|
1154
1214
|
if (shouldRefresh) {
|
|
@@ -1262,6 +1322,25 @@ function inferBlockedReasonFromState(state) {
|
|
|
1262
1322
|
});
|
|
1263
1323
|
}
|
|
1264
1324
|
|
|
1325
|
+
if (state.blocked_on.startsWith('policy:')) {
|
|
1326
|
+
const policyId = getPolicyIdFromBlockedState(state);
|
|
1327
|
+
return buildBlockedReason({
|
|
1328
|
+
category: 'policy_escalation',
|
|
1329
|
+
recovery: {
|
|
1330
|
+
typed_reason: 'policy_escalation',
|
|
1331
|
+
owner: 'human',
|
|
1332
|
+
recovery_action: derivePolicyEscalationRecoveryAction(state, null, {
|
|
1333
|
+
turnRetained,
|
|
1334
|
+
turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
|
|
1335
|
+
policyId,
|
|
1336
|
+
}),
|
|
1337
|
+
turn_retained: turnRetained,
|
|
1338
|
+
detail: derivePolicyEscalationDetail(state, { policyId }),
|
|
1339
|
+
},
|
|
1340
|
+
turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1265
1344
|
return null;
|
|
1266
1345
|
}
|
|
1267
1346
|
|
|
@@ -2047,6 +2126,83 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2047
2126
|
const artifactType = turnResult.artifact?.type || 'review';
|
|
2048
2127
|
const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
|
|
2049
2128
|
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
2129
|
+
|
|
2130
|
+
// Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
|
|
2131
|
+
const policyResult = evaluatePolicies(config.policies || [], {
|
|
2132
|
+
currentPhase: state.phase,
|
|
2133
|
+
turnRole: turnResult.role,
|
|
2134
|
+
turnStatus: turnResult.status,
|
|
2135
|
+
turnCostUsd: readTurnCostUsd(turnResult),
|
|
2136
|
+
history: historyEntries,
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
if (policyResult.blocks.length > 0) {
|
|
2140
|
+
const blockMessages = policyResult.blocks.map((v) => v.message);
|
|
2141
|
+
return {
|
|
2142
|
+
ok: false,
|
|
2143
|
+
error: `Policy violation: ${blockMessages.join('; ')}`,
|
|
2144
|
+
error_code: 'policy_violation',
|
|
2145
|
+
policy_violations: policyResult.violations,
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (policyResult.escalations.length > 0) {
|
|
2150
|
+
const escalationMessages = policyResult.escalations.map((v) => v.message);
|
|
2151
|
+
const policyId = policyResult.escalations[0].policy_id;
|
|
2152
|
+
const turnRetained = getActiveTurnCount(state) > 0;
|
|
2153
|
+
const recovery = {
|
|
2154
|
+
typed_reason: 'policy_escalation',
|
|
2155
|
+
owner: 'human',
|
|
2156
|
+
recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
|
|
2157
|
+
turnRetained,
|
|
2158
|
+
turnId: currentTurn.turn_id,
|
|
2159
|
+
policyId,
|
|
2160
|
+
}),
|
|
2161
|
+
turn_retained: turnRetained,
|
|
2162
|
+
detail: derivePolicyEscalationDetail(state, {
|
|
2163
|
+
policyId,
|
|
2164
|
+
detail: escalationMessages.join('; '),
|
|
2165
|
+
}),
|
|
2166
|
+
};
|
|
2167
|
+
const blockedState = {
|
|
2168
|
+
...state,
|
|
2169
|
+
status: 'blocked',
|
|
2170
|
+
blocked_on: `policy:${policyId}`,
|
|
2171
|
+
blocked_reason: buildBlockedReason({
|
|
2172
|
+
category: 'policy_escalation',
|
|
2173
|
+
recovery,
|
|
2174
|
+
turnId: currentTurn.turn_id,
|
|
2175
|
+
blockedAt: now,
|
|
2176
|
+
}),
|
|
2177
|
+
};
|
|
2178
|
+
writeState(root, blockedState);
|
|
2179
|
+
recordRunHistory(root, blockedState, config, 'blocked');
|
|
2180
|
+
emitBlockedNotification(root, config, blockedState, {
|
|
2181
|
+
category: 'policy_escalation',
|
|
2182
|
+
blockedOn: blockedState.blocked_on,
|
|
2183
|
+
recovery,
|
|
2184
|
+
}, currentTurn);
|
|
2185
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
2186
|
+
timestamp: now,
|
|
2187
|
+
decision: 'policy_escalation',
|
|
2188
|
+
turn_id: currentTurn.turn_id,
|
|
2189
|
+
role: turnResult.role,
|
|
2190
|
+
phase: state.phase,
|
|
2191
|
+
violations: policyResult.escalations.map((v) => ({
|
|
2192
|
+
policy_id: v.policy_id,
|
|
2193
|
+
rule: v.rule,
|
|
2194
|
+
message: v.message,
|
|
2195
|
+
})),
|
|
2196
|
+
});
|
|
2197
|
+
return {
|
|
2198
|
+
ok: false,
|
|
2199
|
+
error: `Policy escalation: ${escalationMessages.join('; ')}`,
|
|
2200
|
+
error_code: 'policy_escalation',
|
|
2201
|
+
state: attachLegacyCurrentTurnAlias(blockedState),
|
|
2202
|
+
policy_violations: policyResult.violations,
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2050
2206
|
const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
|
|
2051
2207
|
|
|
2052
2208
|
if (conflict) {
|
|
@@ -2527,6 +2683,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2527
2683
|
completionResult,
|
|
2528
2684
|
hookResults,
|
|
2529
2685
|
...(budgetWarning ? { budget_warning: budgetWarning } : {}),
|
|
2686
|
+
...(policyResult.warnings.length > 0 ? { policy_warnings: policyResult.warnings } : {}),
|
|
2530
2687
|
};
|
|
2531
2688
|
}
|
|
2532
2689
|
|
|
@@ -80,6 +80,7 @@ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
|
|
|
80
80
|
'runtimes',
|
|
81
81
|
'routing',
|
|
82
82
|
'gates',
|
|
83
|
+
'policies',
|
|
83
84
|
'workflow_kit',
|
|
84
85
|
]);
|
|
85
86
|
|
|
@@ -106,6 +107,7 @@ function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
|
|
|
106
107
|
runtimes: scaffoldBlueprint.runtimes,
|
|
107
108
|
routing: scaffoldBlueprint.routing,
|
|
108
109
|
gates: scaffoldBlueprint.gates,
|
|
110
|
+
policies: scaffoldBlueprint.policies,
|
|
109
111
|
workflow_kit: scaffoldBlueprint.workflow_kit,
|
|
110
112
|
});
|
|
111
113
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { validateHooksConfig } from './hook-runner.js';
|
|
16
16
|
import { validateNotificationsConfig } from './notification-runner.js';
|
|
17
|
+
import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
|
|
17
18
|
import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
18
19
|
import {
|
|
19
20
|
buildDefaultWorkflowKitArtifactsForPhase,
|
|
@@ -24,7 +25,8 @@ import {
|
|
|
24
25
|
|
|
25
26
|
const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
|
|
26
27
|
const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
|
|
27
|
-
const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google'];
|
|
28
|
+
export const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google', 'ollama'];
|
|
29
|
+
const AUTH_OPTIONAL_PROVIDERS = ['ollama'];
|
|
28
30
|
export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
|
|
29
31
|
const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
|
|
30
32
|
const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
|
|
@@ -388,7 +390,9 @@ export function validateV4Config(data, projectRoot) {
|
|
|
388
390
|
errors.push(`Runtime "${id}": api_proxy requires "model" (e.g. "claude-sonnet-4-6")`);
|
|
389
391
|
}
|
|
390
392
|
if (typeof rt.auth_env !== 'string' || !rt.auth_env.trim()) {
|
|
391
|
-
|
|
393
|
+
if (!AUTH_OPTIONAL_PROVIDERS.includes(rt.provider)) {
|
|
394
|
+
errors.push(`Runtime "${id}": api_proxy requires "auth_env" (environment variable name for API key)`);
|
|
395
|
+
}
|
|
392
396
|
}
|
|
393
397
|
if ('base_url' in rt) {
|
|
394
398
|
if (typeof rt.base_url !== 'string' || !rt.base_url.trim()) {
|
|
@@ -509,6 +513,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
509
513
|
errors.push(...wkValidation.errors);
|
|
510
514
|
}
|
|
511
515
|
|
|
516
|
+
// Policies (optional but validated if present)
|
|
517
|
+
if (data.policies !== undefined) {
|
|
518
|
+
const policyValidation = validatePolicies(data.policies);
|
|
519
|
+
errors.push(...policyValidation.errors);
|
|
520
|
+
}
|
|
521
|
+
|
|
512
522
|
return { ok: errors.length === 0, errors };
|
|
513
523
|
}
|
|
514
524
|
|
|
@@ -722,6 +732,7 @@ export function normalizeV3(raw) {
|
|
|
722
732
|
hooks: {},
|
|
723
733
|
notifications: {},
|
|
724
734
|
budget: null,
|
|
735
|
+
policies: [],
|
|
725
736
|
workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
|
|
726
737
|
retention: {
|
|
727
738
|
talk_strategy: 'append_only',
|
|
@@ -786,6 +797,7 @@ export function normalizeV4(raw) {
|
|
|
786
797
|
hooks: raw.hooks || {},
|
|
787
798
|
notifications: raw.notifications || {},
|
|
788
799
|
budget: raw.budget || null,
|
|
800
|
+
policies: normalizePolicies(raw.policies),
|
|
789
801
|
workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
|
|
790
802
|
retention: raw.retention || {
|
|
791
803
|
talk_strategy: 'append_only',
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluator — declarative governance rules for turn acceptance.
|
|
3
|
+
*
|
|
4
|
+
* Policies are config-driven rules that evaluate on every turn acceptance.
|
|
5
|
+
* Gates evaluate at phase boundaries. Hooks run external commands.
|
|
6
|
+
* Policies evaluate built-in governance rules on every accepted turn.
|
|
7
|
+
*
|
|
8
|
+
* Pure functions only — no I/O, no side effects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registry of built-in rule evaluators.
|
|
13
|
+
* Each evaluator: (params, context) → { triggered: boolean, message: string }
|
|
14
|
+
*/
|
|
15
|
+
const RULE_EVALUATORS = {
|
|
16
|
+
max_turns_per_phase: (params, ctx) => {
|
|
17
|
+
const count = ctx.history.filter(
|
|
18
|
+
(entry) => entry.phase === ctx.currentPhase,
|
|
19
|
+
).length;
|
|
20
|
+
if (count >= params.limit) {
|
|
21
|
+
return {
|
|
22
|
+
triggered: true,
|
|
23
|
+
message: `phase "${ctx.currentPhase}" has reached ${count}/${params.limit} accepted turns`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { triggered: false, message: '' };
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
max_total_turns: (params, ctx) => {
|
|
30
|
+
const count = ctx.history.length;
|
|
31
|
+
if (count >= params.limit) {
|
|
32
|
+
return {
|
|
33
|
+
triggered: true,
|
|
34
|
+
message: `run has reached ${count}/${params.limit} total accepted turns`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { triggered: false, message: '' };
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
max_consecutive_same_role: (params, ctx) => {
|
|
41
|
+
const role = ctx.turnRole;
|
|
42
|
+
let consecutive = 0;
|
|
43
|
+
for (let i = ctx.history.length - 1; i >= 0; i--) {
|
|
44
|
+
if (ctx.history[i].role === role) {
|
|
45
|
+
consecutive++;
|
|
46
|
+
} else {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// The current turn (not yet in history) adds one more
|
|
51
|
+
consecutive += 1;
|
|
52
|
+
if (consecutive > params.limit) {
|
|
53
|
+
return {
|
|
54
|
+
triggered: true,
|
|
55
|
+
message: `role "${role}" has ${consecutive} consecutive turns (limit: ${params.limit})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { triggered: false, message: '' };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
max_cost_per_turn: (params, ctx) => {
|
|
62
|
+
const cost = ctx.turnCostUsd;
|
|
63
|
+
if (cost != null && cost > params.limit_usd) {
|
|
64
|
+
return {
|
|
65
|
+
triggered: true,
|
|
66
|
+
message: `turn cost $${cost.toFixed(2)} exceeds limit $${params.limit_usd.toFixed(2)}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { triggered: false, message: '' };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
require_status: (params, ctx) => {
|
|
73
|
+
if (!params.allowed.includes(ctx.turnStatus)) {
|
|
74
|
+
return {
|
|
75
|
+
triggered: true,
|
|
76
|
+
message: `status "${ctx.turnStatus}" is not in allowed set [${params.allowed.join(', ')}]`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { triggered: false, message: '' };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const VALID_POLICY_RULES = Object.keys(RULE_EVALUATORS);
|
|
84
|
+
export const VALID_POLICY_ACTIONS = ['block', 'warn', 'escalate'];
|
|
85
|
+
export const VALID_POLICY_TURN_STATUSES = [
|
|
86
|
+
'completed',
|
|
87
|
+
'blocked',
|
|
88
|
+
'needs_human',
|
|
89
|
+
'failed',
|
|
90
|
+
];
|
|
91
|
+
const VALID_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a single policy definition at config load time.
|
|
95
|
+
* Returns an array of error strings (empty if valid).
|
|
96
|
+
*/
|
|
97
|
+
export function validatePolicy(policy, index) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
const prefix = `policies[${index}]`;
|
|
100
|
+
|
|
101
|
+
if (!policy || typeof policy !== 'object') {
|
|
102
|
+
return [`${prefix}: must be an object`];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof policy.id !== 'string' || !VALID_ID_PATTERN.test(policy.id)) {
|
|
106
|
+
errors.push(`${prefix}: id must be a lowercase kebab-case string`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!VALID_POLICY_RULES.includes(policy.rule)) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`${prefix}: unknown rule "${policy.rule}"; valid rules: ${VALID_POLICY_RULES.join(', ')}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!VALID_POLICY_ACTIONS.includes(policy.action)) {
|
|
116
|
+
errors.push(
|
|
117
|
+
`${prefix}: action must be one of ${VALID_POLICY_ACTIONS.join(', ')}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rule-specific param validation
|
|
122
|
+
if (VALID_POLICY_RULES.includes(policy.rule)) {
|
|
123
|
+
const paramErrors = validatePolicyParams(policy.rule, policy.params, prefix);
|
|
124
|
+
errors.push(...paramErrors);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Scope validation (optional)
|
|
128
|
+
if (policy.scope != null) {
|
|
129
|
+
if (typeof policy.scope !== 'object') {
|
|
130
|
+
errors.push(`${prefix}: scope must be an object`);
|
|
131
|
+
} else {
|
|
132
|
+
if (policy.scope.phases != null && !Array.isArray(policy.scope.phases)) {
|
|
133
|
+
errors.push(`${prefix}: scope.phases must be an array`);
|
|
134
|
+
}
|
|
135
|
+
if (policy.scope.roles != null && !Array.isArray(policy.scope.roles)) {
|
|
136
|
+
errors.push(`${prefix}: scope.roles must be an array`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return errors;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validatePolicyParams(rule, params, prefix) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
|
|
147
|
+
switch (rule) {
|
|
148
|
+
case 'max_turns_per_phase':
|
|
149
|
+
case 'max_total_turns':
|
|
150
|
+
if (!params || typeof params.limit !== 'number' || params.limit < 1) {
|
|
151
|
+
errors.push(`${prefix}: params.limit must be a number >= 1`);
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'max_consecutive_same_role':
|
|
156
|
+
if (!params || typeof params.limit !== 'number' || params.limit < 1) {
|
|
157
|
+
errors.push(`${prefix}: params.limit must be a number >= 1`);
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case 'max_cost_per_turn':
|
|
162
|
+
if (
|
|
163
|
+
!params ||
|
|
164
|
+
typeof params.limit_usd !== 'number' ||
|
|
165
|
+
params.limit_usd <= 0
|
|
166
|
+
) {
|
|
167
|
+
errors.push(`${prefix}: params.limit_usd must be a number > 0`);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case 'require_status':
|
|
172
|
+
if (
|
|
173
|
+
!params ||
|
|
174
|
+
!Array.isArray(params.allowed) ||
|
|
175
|
+
params.allowed.length === 0
|
|
176
|
+
) {
|
|
177
|
+
errors.push(`${prefix}: params.allowed must be a non-empty array`);
|
|
178
|
+
} else {
|
|
179
|
+
for (const status of params.allowed) {
|
|
180
|
+
if (!VALID_POLICY_TURN_STATUSES.includes(status)) {
|
|
181
|
+
errors.push(
|
|
182
|
+
`${prefix}: params.allowed contains invalid status "${status}"; valid statuses: ${VALID_POLICY_TURN_STATUSES.join(', ')}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate the full policies array at config load time.
|
|
195
|
+
* Returns { ok: boolean, errors: string[] }.
|
|
196
|
+
*/
|
|
197
|
+
export function validatePolicies(policies) {
|
|
198
|
+
if (policies == null) {
|
|
199
|
+
return { ok: true, errors: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!Array.isArray(policies)) {
|
|
203
|
+
return { ok: false, errors: ['policies must be an array'] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const errors = [];
|
|
207
|
+
const ids = new Set();
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < policies.length; i++) {
|
|
210
|
+
const policyErrors = validatePolicy(policies[i], i);
|
|
211
|
+
errors.push(...policyErrors);
|
|
212
|
+
|
|
213
|
+
if (policies[i]?.id) {
|
|
214
|
+
if (ids.has(policies[i].id)) {
|
|
215
|
+
errors.push(`policies[${i}]: duplicate id "${policies[i].id}"`);
|
|
216
|
+
}
|
|
217
|
+
ids.add(policies[i].id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { ok: errors.length === 0, errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Evaluate all policies against the current turn context.
|
|
226
|
+
*
|
|
227
|
+
* @param {Array} policies - normalized policies from config
|
|
228
|
+
* @param {object} context
|
|
229
|
+
* @param {string} context.currentPhase
|
|
230
|
+
* @param {string} context.turnRole - role of the turn being accepted
|
|
231
|
+
* @param {string} context.turnStatus - status from turn result
|
|
232
|
+
* @param {number|null} context.turnCostUsd - cost from turn result
|
|
233
|
+
* @param {Array} context.history - accepted history entries
|
|
234
|
+
* @returns {PolicyEvaluationResult}
|
|
235
|
+
*
|
|
236
|
+
* @typedef {object} PolicyEvaluationResult
|
|
237
|
+
* @property {boolean} ok - true if no block/escalate violations
|
|
238
|
+
* @property {PolicyViolation[]} violations - all triggered policies
|
|
239
|
+
* @property {PolicyViolation[]} blocks - violations with action "block"
|
|
240
|
+
* @property {PolicyViolation[]} escalations - violations with action "escalate"
|
|
241
|
+
* @property {PolicyViolation[]} warnings - violations with action "warn"
|
|
242
|
+
*
|
|
243
|
+
* @typedef {object} PolicyViolation
|
|
244
|
+
* @property {string} policy_id
|
|
245
|
+
* @property {string} rule
|
|
246
|
+
* @property {string} action
|
|
247
|
+
* @property {string} message
|
|
248
|
+
*/
|
|
249
|
+
export function evaluatePolicies(policies, context) {
|
|
250
|
+
const result = {
|
|
251
|
+
ok: true,
|
|
252
|
+
violations: [],
|
|
253
|
+
blocks: [],
|
|
254
|
+
escalations: [],
|
|
255
|
+
warnings: [],
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const policy of policies) {
|
|
263
|
+
// Scope check: skip if out of scope
|
|
264
|
+
if (policy.scope) {
|
|
265
|
+
if (
|
|
266
|
+
Array.isArray(policy.scope.phases) &&
|
|
267
|
+
policy.scope.phases.length > 0 &&
|
|
268
|
+
!policy.scope.phases.includes(context.currentPhase)
|
|
269
|
+
) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
Array.isArray(policy.scope.roles) &&
|
|
274
|
+
policy.scope.roles.length > 0 &&
|
|
275
|
+
!policy.scope.roles.includes(context.turnRole)
|
|
276
|
+
) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const evaluator = RULE_EVALUATORS[policy.rule];
|
|
282
|
+
if (!evaluator) {
|
|
283
|
+
continue; // Unknown rules caught at config validation
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const evaluation = evaluator(policy.params || {}, context);
|
|
287
|
+
if (!evaluation.triggered) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const violation = {
|
|
292
|
+
policy_id: policy.id,
|
|
293
|
+
rule: policy.rule,
|
|
294
|
+
action: policy.action,
|
|
295
|
+
message:
|
|
296
|
+
policy.message ||
|
|
297
|
+
`Policy "${policy.id}": ${evaluation.message}`,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
result.violations.push(violation);
|
|
301
|
+
|
|
302
|
+
switch (policy.action) {
|
|
303
|
+
case 'block':
|
|
304
|
+
result.blocks.push(violation);
|
|
305
|
+
break;
|
|
306
|
+
case 'escalate':
|
|
307
|
+
result.escalations.push(violation);
|
|
308
|
+
break;
|
|
309
|
+
case 'warn':
|
|
310
|
+
result.warnings.push(violation);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
result.ok = result.blocks.length === 0 && result.escalations.length === 0;
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Normalize policies config: null/undefined → [], validate, return.
|
|
321
|
+
*/
|
|
322
|
+
export function normalizePolicies(raw) {
|
|
323
|
+
if (raw == null) {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
if (!Array.isArray(raw)) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
return raw;
|
|
330
|
+
}
|
|
@@ -142,6 +142,26 @@
|
|
|
142
142
|
"requires_human_approval": true
|
|
143
143
|
}
|
|
144
144
|
},
|
|
145
|
+
"policies": [
|
|
146
|
+
{
|
|
147
|
+
"id": "phase-turn-cap",
|
|
148
|
+
"rule": "max_turns_per_phase",
|
|
149
|
+
"params": { "limit": 15 },
|
|
150
|
+
"action": "escalate"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "total-turn-cap",
|
|
154
|
+
"rule": "max_total_turns",
|
|
155
|
+
"params": { "limit": 60 },
|
|
156
|
+
"action": "escalate"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "no-role-monopoly",
|
|
160
|
+
"rule": "max_consecutive_same_role",
|
|
161
|
+
"params": { "limit": 4 },
|
|
162
|
+
"action": "block"
|
|
163
|
+
}
|
|
164
|
+
],
|
|
145
165
|
"workflow_kit": {
|
|
146
166
|
"phases": {
|
|
147
167
|
"planning": {
|