scene-capability-engine 3.6.44 → 3.6.46
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/CHANGELOG.md +25 -0
- package/bin/scene-capability-engine.js +36 -2
- package/docs/command-reference.md +5 -0
- package/docs/releases/README.md +2 -0
- package/docs/releases/v3.6.45.md +18 -0
- package/docs/releases/v3.6.46.md +23 -0
- package/docs/zh/releases/README.md +2 -0
- package/docs/zh/releases/v3.6.45.md +18 -0
- package/docs/zh/releases/v3.6.46.md +23 -0
- package/lib/workspace/collab-governance-audit.js +575 -0
- package/package.json +4 -2
- package/scripts/auto-strategy-router.js +231 -0
- package/scripts/capability-mapping-report.js +339 -0
- package/scripts/check-branding-consistency.js +140 -0
- package/scripts/check-sce-tracking.js +54 -0
- package/scripts/check-skip-allowlist.js +94 -0
- package/scripts/errorbook-registry-health-gate.js +172 -0
- package/scripts/errorbook-release-gate.js +132 -0
- package/scripts/failure-attribution-repair.js +317 -0
- package/scripts/git-managed-gate.js +464 -0
- package/scripts/interactive-approval-event-projection.js +400 -0
- package/scripts/interactive-approval-workflow.js +829 -0
- package/scripts/interactive-authorization-tier-evaluate.js +413 -0
- package/scripts/interactive-change-plan-gate.js +225 -0
- package/scripts/interactive-context-bridge.js +617 -0
- package/scripts/interactive-customization-loop.js +1690 -0
- package/scripts/interactive-dialogue-governance.js +842 -0
- package/scripts/interactive-feedback-log.js +253 -0
- package/scripts/interactive-flow-smoke.js +238 -0
- package/scripts/interactive-flow.js +1059 -0
- package/scripts/interactive-governance-report.js +1112 -0
- package/scripts/interactive-intent-build.js +707 -0
- package/scripts/interactive-loop-smoke.js +215 -0
- package/scripts/interactive-moqui-adapter.js +304 -0
- package/scripts/interactive-plan-build.js +426 -0
- package/scripts/interactive-runtime-policy-evaluate.js +495 -0
- package/scripts/interactive-work-order-build.js +552 -0
- package/scripts/matrix-regression-gate.js +167 -0
- package/scripts/moqui-core-regression-suite.js +397 -0
- package/scripts/moqui-lexicon-audit.js +651 -0
- package/scripts/moqui-matrix-remediation-phased-runner.js +865 -0
- package/scripts/moqui-matrix-remediation-queue.js +852 -0
- package/scripts/moqui-metadata-extract.js +1340 -0
- package/scripts/moqui-rebuild-gate.js +167 -0
- package/scripts/moqui-release-summary.js +729 -0
- package/scripts/moqui-standard-rebuild.js +1370 -0
- package/scripts/moqui-template-baseline-report.js +682 -0
- package/scripts/npm-package-runtime-asset-check.js +221 -0
- package/scripts/problem-closure-gate.js +441 -0
- package/scripts/release-asset-integrity-check.js +216 -0
- package/scripts/release-asset-nonempty-normalize.js +166 -0
- package/scripts/release-drift-evaluate.js +223 -0
- package/scripts/release-drift-signals.js +255 -0
- package/scripts/release-governance-snapshot-export.js +132 -0
- package/scripts/release-ops-weekly-summary.js +934 -0
- package/scripts/release-risk-remediation-bundle.js +315 -0
- package/scripts/release-weekly-ops-gate.js +423 -0
- package/scripts/state-migration-reconciliation-gate.js +110 -0
- package/scripts/state-storage-tiering-audit.js +337 -0
- package/scripts/steering-content-audit.js +393 -0
- package/scripts/symbol-evidence-locate.js +366 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_STATE_FILE = '.sce/reports/interactive-approval-state.json';
|
|
9
|
+
const DEFAULT_AUDIT_FILE = '.sce/reports/interactive-approval-events.jsonl';
|
|
10
|
+
const DEFAULT_PASSWORD_HASH_ENV = 'SCE_INTERACTIVE_AUTH_PASSWORD_SHA256';
|
|
11
|
+
const DEFAULT_PASSWORD_TTL_SECONDS = 600;
|
|
12
|
+
const PASSWORD_SCOPES = new Set(['approve', 'execute']);
|
|
13
|
+
const WORKFLOW_ACTIONS = new Set([
|
|
14
|
+
'init',
|
|
15
|
+
'submit',
|
|
16
|
+
'approve',
|
|
17
|
+
'reject',
|
|
18
|
+
'execute',
|
|
19
|
+
'verify',
|
|
20
|
+
'archive',
|
|
21
|
+
'status'
|
|
22
|
+
]);
|
|
23
|
+
const ROLE_SCOPED_ACTIONS = new Set(['submit', 'approve', 'reject', 'execute', 'verify', 'archive']);
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const options = {
|
|
27
|
+
action: null,
|
|
28
|
+
plan: null,
|
|
29
|
+
stateFile: DEFAULT_STATE_FILE,
|
|
30
|
+
auditFile: DEFAULT_AUDIT_FILE,
|
|
31
|
+
actor: null,
|
|
32
|
+
comment: null,
|
|
33
|
+
force: false,
|
|
34
|
+
actorRole: null,
|
|
35
|
+
rolePolicy: null,
|
|
36
|
+
password: null,
|
|
37
|
+
passwordHash: null,
|
|
38
|
+
passwordHashEnv: null,
|
|
39
|
+
passwordRequired: false,
|
|
40
|
+
passwordScope: null,
|
|
41
|
+
passwordTtlSeconds: null,
|
|
42
|
+
json: false
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
46
|
+
const token = argv[i];
|
|
47
|
+
const next = argv[i + 1];
|
|
48
|
+
if (token === '--action' && next) {
|
|
49
|
+
options.action = next;
|
|
50
|
+
i += 1;
|
|
51
|
+
} else if (token === '--plan' && next) {
|
|
52
|
+
options.plan = next;
|
|
53
|
+
i += 1;
|
|
54
|
+
} else if (token === '--state-file' && next) {
|
|
55
|
+
options.stateFile = next;
|
|
56
|
+
i += 1;
|
|
57
|
+
} else if (token === '--audit-file' && next) {
|
|
58
|
+
options.auditFile = next;
|
|
59
|
+
i += 1;
|
|
60
|
+
} else if (token === '--actor' && next) {
|
|
61
|
+
options.actor = next;
|
|
62
|
+
i += 1;
|
|
63
|
+
} else if (token === '--comment' && next) {
|
|
64
|
+
options.comment = next;
|
|
65
|
+
i += 1;
|
|
66
|
+
} else if (token === '--actor-role' && next) {
|
|
67
|
+
options.actorRole = next;
|
|
68
|
+
i += 1;
|
|
69
|
+
} else if (token === '--role-policy' && next) {
|
|
70
|
+
options.rolePolicy = next;
|
|
71
|
+
i += 1;
|
|
72
|
+
} else if (token === '--force') {
|
|
73
|
+
options.force = true;
|
|
74
|
+
} else if (token === '--password' && next) {
|
|
75
|
+
options.password = next;
|
|
76
|
+
i += 1;
|
|
77
|
+
} else if (token === '--password-hash' && next) {
|
|
78
|
+
options.passwordHash = next;
|
|
79
|
+
i += 1;
|
|
80
|
+
} else if (token === '--password-hash-env' && next) {
|
|
81
|
+
options.passwordHashEnv = next;
|
|
82
|
+
i += 1;
|
|
83
|
+
} else if (token === '--password-required') {
|
|
84
|
+
options.passwordRequired = true;
|
|
85
|
+
} else if (token === '--password-scope' && next) {
|
|
86
|
+
options.passwordScope = next;
|
|
87
|
+
i += 1;
|
|
88
|
+
} else if (token === '--password-ttl-seconds' && next) {
|
|
89
|
+
options.passwordTtlSeconds = Number(next);
|
|
90
|
+
i += 1;
|
|
91
|
+
} else if (token === '--json') {
|
|
92
|
+
options.json = true;
|
|
93
|
+
} else if (token === '--help' || token === '-h') {
|
|
94
|
+
printHelpAndExit(0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!options.action) {
|
|
99
|
+
throw new Error('--action is required.');
|
|
100
|
+
}
|
|
101
|
+
const action = `${options.action}`.trim().toLowerCase();
|
|
102
|
+
if (!WORKFLOW_ACTIONS.has(action)) {
|
|
103
|
+
throw new Error(`--action must be one of: ${Array.from(WORKFLOW_ACTIONS).join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
options.action = action;
|
|
106
|
+
|
|
107
|
+
if (action === 'init' && !options.plan) {
|
|
108
|
+
throw new Error('--plan is required for --action init.');
|
|
109
|
+
}
|
|
110
|
+
if (action !== 'status' && `${options.actor || ''}`.trim().length === 0) {
|
|
111
|
+
throw new Error('--actor is required for mutating actions.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.passwordHash && !isSha256Hash(options.passwordHash)) {
|
|
115
|
+
throw new Error('--password-hash must be a sha256 hex string (64 chars).');
|
|
116
|
+
}
|
|
117
|
+
if (options.passwordHashEnv != null && `${options.passwordHashEnv || ''}`.trim().length === 0) {
|
|
118
|
+
throw new Error('--password-hash-env cannot be empty.');
|
|
119
|
+
}
|
|
120
|
+
if (options.actorRole != null && `${options.actorRole || ''}`.trim().length === 0) {
|
|
121
|
+
throw new Error('--actor-role cannot be empty.');
|
|
122
|
+
}
|
|
123
|
+
if (options.rolePolicy != null && `${options.rolePolicy || ''}`.trim().length === 0) {
|
|
124
|
+
throw new Error('--role-policy cannot be empty.');
|
|
125
|
+
}
|
|
126
|
+
if (options.passwordScope != null) {
|
|
127
|
+
options.passwordScope = parsePasswordScope(options.passwordScope, '--password-scope');
|
|
128
|
+
}
|
|
129
|
+
if (options.passwordTtlSeconds != null) {
|
|
130
|
+
if (!Number.isFinite(options.passwordTtlSeconds) || options.passwordTtlSeconds <= 0) {
|
|
131
|
+
throw new Error('--password-ttl-seconds must be a positive number.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
options.passwordHash = options.passwordHash ? options.passwordHash.trim().toLowerCase() : null;
|
|
136
|
+
options.passwordHashEnv = options.passwordHashEnv ? options.passwordHashEnv.trim() : null;
|
|
137
|
+
options.actorRole = options.actorRole ? options.actorRole.trim().toLowerCase() : null;
|
|
138
|
+
options.rolePolicy = options.rolePolicy ? options.rolePolicy.trim() : null;
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function printHelpAndExit(code) {
|
|
143
|
+
const lines = [
|
|
144
|
+
'Usage: node scripts/interactive-approval-workflow.js --action <name> [options]',
|
|
145
|
+
'',
|
|
146
|
+
'Actions:',
|
|
147
|
+
' init, submit, approve, reject, execute, verify, archive, status',
|
|
148
|
+
'',
|
|
149
|
+
'Options:',
|
|
150
|
+
' --action <name> Workflow action (required)',
|
|
151
|
+
' --plan <path> Change plan JSON (required for init)',
|
|
152
|
+
` --state-file <path> Workflow state JSON file (default: ${DEFAULT_STATE_FILE})`,
|
|
153
|
+
` --audit-file <path> Workflow events JSONL file (default: ${DEFAULT_AUDIT_FILE})`,
|
|
154
|
+
' --actor <id> Actor identifier (required for mutating actions)',
|
|
155
|
+
' --actor-role <name> Actor role identifier for role-policy checks',
|
|
156
|
+
' --role-policy <path> Role policy JSON path (optional; enables role checks)',
|
|
157
|
+
' --comment <text> Optional action comment',
|
|
158
|
+
' --force Allow init to overwrite existing state file',
|
|
159
|
+
' --password <text> One-time password input (for password-protected actions)',
|
|
160
|
+
' --password-hash <sha256> Password verifier hash override for init',
|
|
161
|
+
` --password-hash-env <name> Environment variable that stores password hash (default: ${DEFAULT_PASSWORD_HASH_ENV})`,
|
|
162
|
+
' --password-required Force password authorization requirement in init',
|
|
163
|
+
' --password-scope <csv> Password scope override: approve,execute',
|
|
164
|
+
` --password-ttl-seconds <n> Password verification TTL seconds (default: ${DEFAULT_PASSWORD_TTL_SECONDS})`,
|
|
165
|
+
' --json Print JSON payload',
|
|
166
|
+
' -h, --help Show this help'
|
|
167
|
+
];
|
|
168
|
+
console.log(lines.join('\n'));
|
|
169
|
+
process.exit(code);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolvePath(cwd, value) {
|
|
173
|
+
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readJsonFile(filePath, label) {
|
|
177
|
+
if (!(await fs.pathExists(filePath))) {
|
|
178
|
+
throw new Error(`${label} not found: ${filePath}`);
|
|
179
|
+
}
|
|
180
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(content);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new Error(`invalid JSON in ${label}: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveRoleRequirements(cwd, options, plan) {
|
|
189
|
+
let fromPolicy = {};
|
|
190
|
+
if (options.rolePolicy) {
|
|
191
|
+
const rolePolicyPath = resolvePath(cwd, options.rolePolicy);
|
|
192
|
+
const rolePolicy = await readJsonFile(rolePolicyPath, 'role-policy');
|
|
193
|
+
const policyRoles = rolePolicy && rolePolicy.role_requirements && typeof rolePolicy.role_requirements === 'object'
|
|
194
|
+
? rolePolicy.role_requirements
|
|
195
|
+
: {};
|
|
196
|
+
fromPolicy = normalizeRoleRequirements(policyRoles);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const planAuthorization = plan && plan.authorization && typeof plan.authorization === 'object'
|
|
200
|
+
? plan.authorization
|
|
201
|
+
: {};
|
|
202
|
+
const fromPlan = normalizeRoleRequirements(planAuthorization.role_requirements || {});
|
|
203
|
+
return mergeRoleRequirements(fromPolicy, fromPlan);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeRiskLevel(value) {
|
|
207
|
+
const normalized = `${value || ''}`.trim().toLowerCase();
|
|
208
|
+
return ['low', 'medium', 'high'].includes(normalized) ? normalized : 'medium';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createEvent(state, action, actor, actorRole, comment, fromStatus, toStatus, blocked, reason) {
|
|
212
|
+
return {
|
|
213
|
+
event_id: `event-${crypto.randomUUID()}`,
|
|
214
|
+
workflow_id: state && state.workflow_id ? state.workflow_id : null,
|
|
215
|
+
event_type: blocked ? 'interactive.approval.blocked' : `interactive.approval.${action}`,
|
|
216
|
+
action,
|
|
217
|
+
actor,
|
|
218
|
+
actor_role: actorRole || null,
|
|
219
|
+
comment: comment || null,
|
|
220
|
+
from_status: fromStatus || null,
|
|
221
|
+
to_status: toStatus || null,
|
|
222
|
+
blocked: blocked === true,
|
|
223
|
+
reason: reason || null,
|
|
224
|
+
timestamp: new Date().toISOString()
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toBoolean(value) {
|
|
229
|
+
return value === true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isSha256Hash(value) {
|
|
233
|
+
return /^[a-fA-F0-9]{64}$/.test(`${value || ''}`.trim());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sha256(value) {
|
|
237
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parsePasswordScope(value, label = 'password scope') {
|
|
241
|
+
const tokens = `${value || ''}`
|
|
242
|
+
.split(',')
|
|
243
|
+
.map(item => item.trim().toLowerCase())
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
const unique = Array.from(new Set(tokens));
|
|
246
|
+
const invalid = unique.filter(item => !PASSWORD_SCOPES.has(item));
|
|
247
|
+
if (invalid.length > 0) {
|
|
248
|
+
throw new Error(`${label} supports only: approve, execute`);
|
|
249
|
+
}
|
|
250
|
+
return unique;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizePasswordScope(value, fallback = []) {
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
const normalized = value
|
|
256
|
+
.map(item => `${item || ''}`.trim().toLowerCase())
|
|
257
|
+
.filter(item => PASSWORD_SCOPES.has(item));
|
|
258
|
+
return Array.from(new Set(normalized));
|
|
259
|
+
}
|
|
260
|
+
if (typeof value === 'string' && value.trim()) {
|
|
261
|
+
try {
|
|
262
|
+
return parsePasswordScope(value);
|
|
263
|
+
} catch (_error) {
|
|
264
|
+
return [...fallback];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...fallback];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeRoleName(value) {
|
|
271
|
+
return `${value || ''}`.trim().toLowerCase();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeRoleList(value) {
|
|
275
|
+
if (Array.isArray(value)) {
|
|
276
|
+
return Array.from(new Set(value.map(item => normalizeRoleName(item)).filter(Boolean)));
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === 'string' && value.trim()) {
|
|
279
|
+
return Array.from(new Set(
|
|
280
|
+
value
|
|
281
|
+
.split(',')
|
|
282
|
+
.map(item => normalizeRoleName(item))
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
));
|
|
285
|
+
}
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normalizeRoleRequirements(value = {}) {
|
|
290
|
+
if (!value || typeof value !== 'object') {
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
293
|
+
const normalized = {};
|
|
294
|
+
for (const key of Object.keys(value)) {
|
|
295
|
+
const action = normalizeRoleName(key);
|
|
296
|
+
if (!ROLE_SCOPED_ACTIONS.has(action)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const roles = normalizeRoleList(value[key]);
|
|
300
|
+
if (roles.length > 0) {
|
|
301
|
+
normalized[action] = roles;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return normalized;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function mergeRoleRequirements(base = {}, override = {}) {
|
|
308
|
+
const merged = { ...base };
|
|
309
|
+
const normalizedOverride = normalizeRoleRequirements(override);
|
|
310
|
+
for (const action of Object.keys(normalizedOverride)) {
|
|
311
|
+
merged[action] = normalizedOverride[action];
|
|
312
|
+
}
|
|
313
|
+
return merged;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resolvePasswordTtlSeconds(optionsTtl, planTtl) {
|
|
317
|
+
const fromOptions = Number(optionsTtl);
|
|
318
|
+
if (Number.isFinite(fromOptions) && fromOptions > 0) {
|
|
319
|
+
return Math.floor(fromOptions);
|
|
320
|
+
}
|
|
321
|
+
const fromPlan = Number(planTtl);
|
|
322
|
+
if (Number.isFinite(fromPlan) && fromPlan > 0) {
|
|
323
|
+
return Math.floor(fromPlan);
|
|
324
|
+
}
|
|
325
|
+
return DEFAULT_PASSWORD_TTL_SECONDS;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isMutatingPlanAction(item) {
|
|
329
|
+
return `${item && item.type ? item.type : ''}`.trim().toLowerCase() !== 'analysis_only';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function computeApprovalFlags(plan) {
|
|
333
|
+
const actions = Array.isArray(plan && plan.actions)
|
|
334
|
+
? plan.actions.filter(item => item && typeof item === 'object')
|
|
335
|
+
: [];
|
|
336
|
+
const riskLevel = normalizeRiskLevel(plan && plan.risk_level);
|
|
337
|
+
const privilegeEscalationDetected = actions.some(item => item.requires_privilege_escalation === true);
|
|
338
|
+
const approvalRequired = (
|
|
339
|
+
riskLevel === 'high' ||
|
|
340
|
+
privilegeEscalationDetected ||
|
|
341
|
+
(plan && plan.approval && plan.approval.status === 'pending') ||
|
|
342
|
+
(plan && plan.approval && plan.approval.status === 'approved')
|
|
343
|
+
);
|
|
344
|
+
const dualApprovalRequired = toBoolean(
|
|
345
|
+
plan && plan.approval && plan.approval.dual_approved === false && privilegeEscalationDetected
|
|
346
|
+
);
|
|
347
|
+
return {
|
|
348
|
+
approval_required: approvalRequired,
|
|
349
|
+
dual_approval_required: dualApprovalRequired,
|
|
350
|
+
privilege_escalation_detected: privilegeEscalationDetected
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function computeAuthorization(plan, options = {}) {
|
|
355
|
+
const authorization = plan && plan.authorization && typeof plan.authorization === 'object'
|
|
356
|
+
? plan.authorization
|
|
357
|
+
: {};
|
|
358
|
+
const actions = Array.isArray(plan && plan.actions)
|
|
359
|
+
? plan.actions.filter(item => item && typeof item === 'object')
|
|
360
|
+
: [];
|
|
361
|
+
const executionMode = `${plan && plan.execution_mode ? plan.execution_mode : ''}`.trim().toLowerCase();
|
|
362
|
+
const mutating = actions.some(item => isMutatingPlanAction(item));
|
|
363
|
+
|
|
364
|
+
const passwordRequired = (
|
|
365
|
+
options.passwordRequired === true ||
|
|
366
|
+
authorization.password_required === true ||
|
|
367
|
+
(executionMode === 'apply' && mutating)
|
|
368
|
+
);
|
|
369
|
+
const passwordScope = passwordRequired
|
|
370
|
+
? (
|
|
371
|
+
(Array.isArray(options.passwordScope) && options.passwordScope.length > 0)
|
|
372
|
+
? [...options.passwordScope]
|
|
373
|
+
: normalizePasswordScope(authorization.password_scope, ['execute'])
|
|
374
|
+
)
|
|
375
|
+
: [];
|
|
376
|
+
const passwordHash = options.passwordHash
|
|
377
|
+
? options.passwordHash
|
|
378
|
+
: (isSha256Hash(authorization.password_hash) ? String(authorization.password_hash).trim().toLowerCase() : null);
|
|
379
|
+
const passwordHashEnv = options.passwordHashEnv
|
|
380
|
+
? options.passwordHashEnv
|
|
381
|
+
: `${authorization.password_hash_env || DEFAULT_PASSWORD_HASH_ENV}`.trim() || DEFAULT_PASSWORD_HASH_ENV;
|
|
382
|
+
const passwordTtlSeconds = resolvePasswordTtlSeconds(options.passwordTtlSeconds, authorization.password_ttl_seconds);
|
|
383
|
+
const reasonCodes = Array.isArray(authorization.reason_codes)
|
|
384
|
+
? authorization.reason_codes.map(item => `${item || ''}`.trim()).filter(Boolean)
|
|
385
|
+
: [];
|
|
386
|
+
const roleRequirements = normalizeRoleRequirements(options.roleRequirements || authorization.role_requirements || {});
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
password_required: passwordRequired,
|
|
390
|
+
password_scope: passwordScope,
|
|
391
|
+
password_hash: passwordHash,
|
|
392
|
+
password_hash_env: passwordHashEnv,
|
|
393
|
+
password_ttl_seconds: passwordTtlSeconds,
|
|
394
|
+
password_verified: false,
|
|
395
|
+
password_verified_at: null,
|
|
396
|
+
password_verified_by: null,
|
|
397
|
+
password_expires_at: null,
|
|
398
|
+
reason_codes: reasonCodes,
|
|
399
|
+
role_requirements: roleRequirements
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function buildInitialState(plan, actor, comment, options) {
|
|
404
|
+
const now = new Date().toISOString();
|
|
405
|
+
const flags = computeApprovalFlags(plan);
|
|
406
|
+
const initial = {
|
|
407
|
+
mode: 'interactive-approval-workflow',
|
|
408
|
+
workflow_id: `wf-${crypto.randomUUID()}`,
|
|
409
|
+
plan_id: plan.plan_id || null,
|
|
410
|
+
intent_id: plan.intent_id || null,
|
|
411
|
+
risk_level: normalizeRiskLevel(plan.risk_level),
|
|
412
|
+
execution_mode: `${plan.execution_mode || 'suggestion'}`.trim() || 'suggestion',
|
|
413
|
+
approval_required: flags.approval_required,
|
|
414
|
+
dual_approval_required: flags.dual_approval_required,
|
|
415
|
+
privilege_escalation_detected: flags.privilege_escalation_detected,
|
|
416
|
+
status: 'draft',
|
|
417
|
+
approvals: {
|
|
418
|
+
status: flags.approval_required ? 'pending' : 'not-required',
|
|
419
|
+
approvers: [],
|
|
420
|
+
rejected_by: null
|
|
421
|
+
},
|
|
422
|
+
authorization: computeAuthorization(plan, options),
|
|
423
|
+
history: [],
|
|
424
|
+
created_at: now,
|
|
425
|
+
updated_at: now
|
|
426
|
+
};
|
|
427
|
+
const event = createEvent(
|
|
428
|
+
initial,
|
|
429
|
+
'init',
|
|
430
|
+
actor,
|
|
431
|
+
options && options.actorRole ? options.actorRole : null,
|
|
432
|
+
comment,
|
|
433
|
+
null,
|
|
434
|
+
'draft',
|
|
435
|
+
false,
|
|
436
|
+
null
|
|
437
|
+
);
|
|
438
|
+
initial.history.push(event);
|
|
439
|
+
return { state: initial, event };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function assertTransition(state, fromList, action) {
|
|
443
|
+
if (!fromList.includes(state.status)) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
reason: `cannot ${action} when status is ${state.status}; expected ${fromList.join('|')}`
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return { ok: true, reason: null };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function resolveVerifierHash(state, env = process.env) {
|
|
453
|
+
const authorization = state && state.authorization && typeof state.authorization === 'object'
|
|
454
|
+
? state.authorization
|
|
455
|
+
: {};
|
|
456
|
+
if (authorization.password_hash && isSha256Hash(authorization.password_hash)) {
|
|
457
|
+
return String(authorization.password_hash).trim().toLowerCase();
|
|
458
|
+
}
|
|
459
|
+
const envName = `${authorization.password_hash_env || DEFAULT_PASSWORD_HASH_ENV}`.trim();
|
|
460
|
+
const fromEnv = env && Object.prototype.hasOwnProperty.call(env, envName)
|
|
461
|
+
? `${env[envName] || ''}`.trim().toLowerCase()
|
|
462
|
+
: '';
|
|
463
|
+
if (isSha256Hash(fromEnv)) {
|
|
464
|
+
return fromEnv;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isPasswordVerificationActive(authorization, nowMs) {
|
|
470
|
+
if (!authorization || authorization.password_verified !== true) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
const expires = authorization.password_expires_at
|
|
474
|
+
? Date.parse(authorization.password_expires_at)
|
|
475
|
+
: Number.NaN;
|
|
476
|
+
if (!Number.isFinite(expires)) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
return nowMs < expires;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requirePasswordForAction(state, options, action, nowIso) {
|
|
483
|
+
const authorization = state && state.authorization && typeof state.authorization === 'object'
|
|
484
|
+
? state.authorization
|
|
485
|
+
: {};
|
|
486
|
+
const scope = normalizePasswordScope(authorization.password_scope, []);
|
|
487
|
+
if (authorization.password_required !== true || !scope.includes(action)) {
|
|
488
|
+
return { ok: true, reason: null };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const nowMs = Date.parse(nowIso);
|
|
492
|
+
if (isPasswordVerificationActive(authorization, nowMs)) {
|
|
493
|
+
return { ok: true, reason: null };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const verifierHash = resolveVerifierHash(state, process.env);
|
|
497
|
+
if (!verifierHash) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
reason: 'password authorization required but verifier hash is not configured'
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const candidate = `${options.password || ''}`;
|
|
505
|
+
if (!candidate.trim()) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
reason: `password authorization required for ${action}`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const candidateHash = sha256(candidate).toLowerCase();
|
|
513
|
+
if (candidateHash !== verifierHash) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
reason: 'password authorization failed'
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const ttlSeconds = resolvePasswordTtlSeconds(
|
|
521
|
+
authorization.password_ttl_seconds,
|
|
522
|
+
DEFAULT_PASSWORD_TTL_SECONDS
|
|
523
|
+
);
|
|
524
|
+
authorization.password_verified = true;
|
|
525
|
+
authorization.password_verified_by = `${options.actor || ''}`.trim() || null;
|
|
526
|
+
authorization.password_verified_at = nowIso;
|
|
527
|
+
authorization.password_expires_at = new Date(nowMs + (ttlSeconds * 1000)).toISOString();
|
|
528
|
+
state.authorization = authorization;
|
|
529
|
+
return { ok: true, reason: null };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function requireRoleForAction(state, options, action) {
|
|
533
|
+
const authorization = state && state.authorization && typeof state.authorization === 'object'
|
|
534
|
+
? state.authorization
|
|
535
|
+
: {};
|
|
536
|
+
const roleRequirements = authorization && authorization.role_requirements && typeof authorization.role_requirements === 'object'
|
|
537
|
+
? authorization.role_requirements
|
|
538
|
+
: {};
|
|
539
|
+
const allowedRoles = normalizeRoleList(roleRequirements[action]);
|
|
540
|
+
if (allowedRoles.length === 0) {
|
|
541
|
+
return { ok: true, reason: null };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const actorRole = normalizeRoleName(options && options.actorRole);
|
|
545
|
+
if (!actorRole) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
reason: `actor role required for ${action}; allowed roles: ${allowedRoles.join(', ')}`
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (!allowedRoles.includes(actorRole)) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reason: `actor role "${actorRole}" is not allowed for ${action}; allowed roles: ${allowedRoles.join(', ')}`
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return { ok: true, reason: null };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function mutateStateForAction(state, options) {
|
|
561
|
+
const actor = `${options.actor || ''}`.trim();
|
|
562
|
+
const comment = options.comment || null;
|
|
563
|
+
const action = options.action;
|
|
564
|
+
const now = new Date().toISOString();
|
|
565
|
+
let fromStatus = state.status;
|
|
566
|
+
let toStatus = state.status;
|
|
567
|
+
let blocked = false;
|
|
568
|
+
let reason = null;
|
|
569
|
+
|
|
570
|
+
const fail = (message) => {
|
|
571
|
+
blocked = true;
|
|
572
|
+
reason = message;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
if (action === 'status') {
|
|
576
|
+
fromStatus = state.status;
|
|
577
|
+
toStatus = state.status;
|
|
578
|
+
} else if (action === 'submit') {
|
|
579
|
+
const check = assertTransition(state, ['draft'], action);
|
|
580
|
+
if (!check.ok) {
|
|
581
|
+
fail(check.reason);
|
|
582
|
+
} else {
|
|
583
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
584
|
+
if (!roleCheck.ok) {
|
|
585
|
+
fail(roleCheck.reason);
|
|
586
|
+
} else {
|
|
587
|
+
toStatus = 'submitted';
|
|
588
|
+
state.status = toStatus;
|
|
589
|
+
if (state.approval_required && state.approvals.status === 'not-required') {
|
|
590
|
+
state.approvals.status = 'pending';
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} else if (action === 'approve') {
|
|
595
|
+
const check = assertTransition(state, ['submitted'], action);
|
|
596
|
+
if (!check.ok) {
|
|
597
|
+
fail(check.reason);
|
|
598
|
+
} else {
|
|
599
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
600
|
+
if (!roleCheck.ok) {
|
|
601
|
+
fail(roleCheck.reason);
|
|
602
|
+
} else {
|
|
603
|
+
const auth = requirePasswordForAction(state, options, action, now);
|
|
604
|
+
if (!auth.ok) {
|
|
605
|
+
fail(auth.reason);
|
|
606
|
+
} else {
|
|
607
|
+
toStatus = 'approved';
|
|
608
|
+
state.status = toStatus;
|
|
609
|
+
if (!state.approvals.approvers.includes(actor)) {
|
|
610
|
+
state.approvals.approvers.push(actor);
|
|
611
|
+
}
|
|
612
|
+
state.approvals.status = 'approved';
|
|
613
|
+
state.approvals.rejected_by = null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else if (action === 'reject') {
|
|
618
|
+
const check = assertTransition(state, ['submitted'], action);
|
|
619
|
+
if (!check.ok) {
|
|
620
|
+
fail(check.reason);
|
|
621
|
+
} else {
|
|
622
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
623
|
+
if (!roleCheck.ok) {
|
|
624
|
+
fail(roleCheck.reason);
|
|
625
|
+
} else {
|
|
626
|
+
toStatus = 'rejected';
|
|
627
|
+
state.status = toStatus;
|
|
628
|
+
state.approvals.status = 'rejected';
|
|
629
|
+
state.approvals.rejected_by = actor;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} else if (action === 'execute') {
|
|
633
|
+
const validFrom = ['submitted', 'approved'];
|
|
634
|
+
const check = assertTransition(state, validFrom, action);
|
|
635
|
+
if (!check.ok) {
|
|
636
|
+
fail(check.reason);
|
|
637
|
+
} else if (state.approval_required && state.status !== 'approved') {
|
|
638
|
+
fail('approval required before execute');
|
|
639
|
+
} else {
|
|
640
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
641
|
+
if (!roleCheck.ok) {
|
|
642
|
+
fail(roleCheck.reason);
|
|
643
|
+
} else {
|
|
644
|
+
const auth = requirePasswordForAction(state, options, action, now);
|
|
645
|
+
if (!auth.ok) {
|
|
646
|
+
fail(auth.reason);
|
|
647
|
+
} else {
|
|
648
|
+
toStatus = 'executed';
|
|
649
|
+
state.status = toStatus;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} else if (action === 'verify') {
|
|
654
|
+
const check = assertTransition(state, ['executed'], action);
|
|
655
|
+
if (!check.ok) {
|
|
656
|
+
fail(check.reason);
|
|
657
|
+
} else {
|
|
658
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
659
|
+
if (!roleCheck.ok) {
|
|
660
|
+
fail(roleCheck.reason);
|
|
661
|
+
} else {
|
|
662
|
+
toStatus = 'verified';
|
|
663
|
+
state.status = toStatus;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} else if (action === 'archive') {
|
|
667
|
+
const check = assertTransition(state, ['verified', 'rejected'], action);
|
|
668
|
+
if (!check.ok) {
|
|
669
|
+
fail(check.reason);
|
|
670
|
+
} else {
|
|
671
|
+
const roleCheck = requireRoleForAction(state, options, action);
|
|
672
|
+
if (!roleCheck.ok) {
|
|
673
|
+
fail(roleCheck.reason);
|
|
674
|
+
} else {
|
|
675
|
+
toStatus = 'archived';
|
|
676
|
+
state.status = toStatus;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
} else {
|
|
680
|
+
fail(`unsupported action: ${action}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const event = createEvent(
|
|
684
|
+
state,
|
|
685
|
+
action,
|
|
686
|
+
actor,
|
|
687
|
+
options && options.actorRole ? options.actorRole : null,
|
|
688
|
+
comment,
|
|
689
|
+
fromStatus,
|
|
690
|
+
blocked ? fromStatus : toStatus,
|
|
691
|
+
blocked,
|
|
692
|
+
reason
|
|
693
|
+
);
|
|
694
|
+
state.history.push(event);
|
|
695
|
+
state.updated_at = now;
|
|
696
|
+
return { blocked, reason, event };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function appendAuditLine(auditPath, event) {
|
|
700
|
+
await fs.ensureDir(path.dirname(auditPath));
|
|
701
|
+
await fs.appendFile(auditPath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function buildAuthorizationSummary(state) {
|
|
705
|
+
const authorization = state && state.authorization && typeof state.authorization === 'object'
|
|
706
|
+
? state.authorization
|
|
707
|
+
: {};
|
|
708
|
+
return {
|
|
709
|
+
password_required: authorization.password_required === true,
|
|
710
|
+
password_scope: Array.isArray(authorization.password_scope) ? authorization.password_scope : [],
|
|
711
|
+
password_verified: authorization.password_verified === true,
|
|
712
|
+
password_verified_at: authorization.password_verified_at || null,
|
|
713
|
+
password_expires_at: authorization.password_expires_at || null,
|
|
714
|
+
password_hash_env: authorization.password_hash_env || DEFAULT_PASSWORD_HASH_ENV,
|
|
715
|
+
verifier_configured: Boolean(resolveVerifierHash(state, process.env)),
|
|
716
|
+
role_requirements: normalizeRoleRequirements(authorization.role_requirements || {})
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function buildOutput(state, options, statePath, auditPath, decision, reason) {
|
|
721
|
+
const publicState = JSON.parse(JSON.stringify(state || {}));
|
|
722
|
+
if (
|
|
723
|
+
publicState.authorization &&
|
|
724
|
+
typeof publicState.authorization === 'object' &&
|
|
725
|
+
publicState.authorization.password_hash
|
|
726
|
+
) {
|
|
727
|
+
publicState.authorization.password_hash = '***';
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
mode: 'interactive-approval-workflow',
|
|
731
|
+
generated_at: new Date().toISOString(),
|
|
732
|
+
action: options.action,
|
|
733
|
+
actor: options.actor || null,
|
|
734
|
+
actor_role: options.actorRole || null,
|
|
735
|
+
decision,
|
|
736
|
+
reason: reason || null,
|
|
737
|
+
state: publicState,
|
|
738
|
+
authorization: buildAuthorizationSummary(state),
|
|
739
|
+
output: {
|
|
740
|
+
state: path.relative(process.cwd(), statePath) || '.',
|
|
741
|
+
audit: path.relative(process.cwd(), auditPath) || '.'
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function main() {
|
|
747
|
+
const options = parseArgs(process.argv.slice(2));
|
|
748
|
+
const cwd = process.cwd();
|
|
749
|
+
const statePath = resolvePath(cwd, options.stateFile);
|
|
750
|
+
const auditPath = resolvePath(cwd, options.auditFile);
|
|
751
|
+
|
|
752
|
+
if (options.action === 'init') {
|
|
753
|
+
if ((await fs.pathExists(statePath)) && !options.force) {
|
|
754
|
+
throw new Error(`state file already exists; use --force to re-init: ${statePath}`);
|
|
755
|
+
}
|
|
756
|
+
const planPath = resolvePath(cwd, options.plan);
|
|
757
|
+
const plan = await readJsonFile(planPath, 'plan');
|
|
758
|
+
const roleRequirements = await resolveRoleRequirements(cwd, options, plan);
|
|
759
|
+
const initOptions = {
|
|
760
|
+
...options,
|
|
761
|
+
roleRequirements
|
|
762
|
+
};
|
|
763
|
+
const { state, event } = buildInitialState(plan, options.actor, options.comment, initOptions);
|
|
764
|
+
await fs.ensureDir(path.dirname(statePath));
|
|
765
|
+
await fs.writeJson(statePath, state, { spaces: 2 });
|
|
766
|
+
await appendAuditLine(auditPath, event);
|
|
767
|
+
const payload = buildOutput(state, options, statePath, auditPath, 'ok', null);
|
|
768
|
+
if (options.json) {
|
|
769
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
770
|
+
} else {
|
|
771
|
+
process.stdout.write(`Interactive approval workflow initialized: ${state.workflow_id}\n`);
|
|
772
|
+
process.stdout.write(`- State: ${payload.output.state}\n`);
|
|
773
|
+
process.stdout.write(`- Audit: ${payload.output.audit}\n`);
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!(await fs.pathExists(statePath))) {
|
|
779
|
+
throw new Error(`state file not found: ${statePath}`);
|
|
780
|
+
}
|
|
781
|
+
const state = await readJsonFile(statePath, 'state-file');
|
|
782
|
+
|
|
783
|
+
if (options.action === 'status') {
|
|
784
|
+
const payload = buildOutput(state, options, statePath, auditPath, 'ok', null);
|
|
785
|
+
if (options.json) {
|
|
786
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
787
|
+
} else {
|
|
788
|
+
process.stdout.write(`Interactive approval status: ${state.status}\n`);
|
|
789
|
+
process.stdout.write(`- State: ${payload.output.state}\n`);
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const result = mutateStateForAction(state, options);
|
|
795
|
+
await fs.ensureDir(path.dirname(statePath));
|
|
796
|
+
await fs.writeJson(statePath, state, { spaces: 2 });
|
|
797
|
+
await appendAuditLine(auditPath, result.event);
|
|
798
|
+
const payload = buildOutput(
|
|
799
|
+
state,
|
|
800
|
+
options,
|
|
801
|
+
statePath,
|
|
802
|
+
auditPath,
|
|
803
|
+
result.blocked ? 'blocked' : 'ok',
|
|
804
|
+
result.reason
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
if (options.json) {
|
|
808
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
809
|
+
} else {
|
|
810
|
+
process.stdout.write(`Interactive approval action ${options.action}: ${payload.decision}\n`);
|
|
811
|
+
process.stdout.write(`- Status: ${state.status}\n`);
|
|
812
|
+
if (payload.reason) {
|
|
813
|
+
process.stdout.write(`- Reason: ${payload.reason}\n`);
|
|
814
|
+
}
|
|
815
|
+
process.stdout.write(`- Password required: ${payload.authorization.password_required ? 'yes' : 'no'}\n`);
|
|
816
|
+
process.stdout.write(`- Password verified: ${payload.authorization.password_verified ? 'yes' : 'no'}\n`);
|
|
817
|
+
process.stdout.write(`- State: ${payload.output.state}\n`);
|
|
818
|
+
process.stdout.write(`- Audit: ${payload.output.audit}\n`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (result.blocked) {
|
|
822
|
+
process.exitCode = 2;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
main().catch((error) => {
|
|
827
|
+
console.error(`Interactive approval workflow failed: ${error.message}`);
|
|
828
|
+
process.exit(1);
|
|
829
|
+
});
|