@cyanautomation/kaseki-agent 1.64.0 → 1.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kaseki-agent.sh CHANGED
@@ -470,143 +470,15 @@ validate_scouting_artifact_with_node() {
470
470
  local final_artifact="$2"
471
471
  local validation_error_file="$3"
472
472
 
473
- # shellcheck disable=SC2016
474
- node -e '
475
- const fs = require("node:fs");
476
- const input = process.argv[1];
477
- const output = process.argv[2];
478
- const errorLog = process.argv[3];
479
- const jsonlLog = "/results/scouting-validation-errors.jsonl";
480
-
481
- function actualType(value) {
482
- if (value === null) return "null";
483
- if (Array.isArray(value)) return "array";
484
- return typeof value;
485
- }
486
-
487
- function appendValidationFailure(reasonCode, error) {
488
- fs.appendFileSync(jsonlLog, JSON.stringify({
489
- timestamp: new Date().toISOString(),
490
- reason_code: reasonCode,
491
- ...error,
492
- }) + "\n");
493
- }
494
-
495
- function summarize(errors) {
496
- const critical = errors.filter((error) => error.severity === "critical").length;
497
- const warning = errors.filter((error) => error.severity === "warning").length;
498
- const counts = [];
499
- if (critical) counts.push(`${critical} critical`);
500
- if (warning) counts.push(`${warning} warning`);
501
- const fields = errors.slice(0, 2).map((error) => error.field).join(", ");
502
- const suffix = errors.length > 2 ? `, +${errors.length - 2} more` : "";
503
- return `${counts.join(", ")} scouting validation ${errors.length === 1 ? "error" : "errors"}: ${fields}${suffix}`;
504
- }
505
-
506
- let artifact;
507
- try {
508
- artifact = JSON.parse(fs.readFileSync(input, "utf8"));
509
- } catch (err) {
510
- const reasonCode = "malformed_json";
511
- const error = {
512
- field: "root",
513
- expected: "exactly one valid JSON object",
514
- actual: err && err.message ? String(err.message) : "JSON parse failed",
515
- severity: "critical",
516
- suggestion: "ensure exactly one valid JSON object is written to /results/scouting-candidate.json",
517
- };
518
- appendValidationFailure(reasonCode, error);
519
- fs.writeFileSync(errorLog, JSON.stringify({
520
- reason_code: reasonCode,
521
- details: summarize([error]),
522
- errors: [error],
523
- }) + "\n");
524
- process.exit(1);
473
+ node "$SCOUTING_ALLOWLIST_HELPER" validate \
474
+ "$candidate_artifact" \
475
+ "$final_artifact" \
476
+ "$validation_error_file" \
477
+ "/results/scouting-validation-errors.jsonl" \
478
+ >/dev/null \
479
+ 2>> /results/scouting-stderr.log
525
480
  }
526
481
 
527
- const errors = [];
528
- const addError = (field, expected, actual, severity, suggestion) => {
529
- errors.push({ field, expected, actual, severity, suggestion });
530
- };
531
- const arrayKeys = ["requirements", "relevant_files", "observations", "plan", "validation", "risks", "test_impact"];
532
-
533
- if (!artifact || Array.isArray(artifact) || typeof artifact !== "object") {
534
- addError("root", "object", actualType(artifact), "critical", "Scouting artifact must be a JSON object, not an array/null/primitive");
535
- } else {
536
- if (typeof artifact.task !== "string" || !artifact.task.trim()) {
537
- addError("task", "non-empty string", typeof artifact.task === "string" ? "empty string" : actualType(artifact.task), "critical", "task must be a non-empty string describing the requested work");
538
- }
539
- for (const key of arrayKeys) {
540
- if (!Array.isArray(artifact[key])) {
541
- addError(key, "array", actualType(artifact[key]), "critical", `${key} must be an array in the scouting handoff`);
542
- }
543
- }
544
- if (Array.isArray(artifact.relevant_files)) {
545
- artifact.relevant_files.forEach((item, index) => {
546
- if (!item || typeof item.path !== "string" || typeof item.reason !== "string") {
547
- addError(`relevant_files[${index}]`, "object with string path and string reason", actualType(item), "warning", "Each relevant_files entry must include path and reason strings");
548
- }
549
- });
550
- }
551
- if (Array.isArray(artifact.test_impact)) {
552
- artifact.test_impact.forEach((item, index) => {
553
- if (!item || typeof item.path !== "string" || !item.path.trim() || typeof item.reason !== "string" || !item.reason.trim()) {
554
- addError(`test_impact[${index}]`, "object with non-empty string path and non-empty string reason", actualType(item), "critical", "Each test_impact entry must include the impacted test path and expectation reason strings");
555
- }
556
- // Validate optional test_examples field
557
- if (item.test_examples !== undefined) {
558
- if (!Array.isArray(item.test_examples)) {
559
- addError(`test_impact[${index}].test_examples`, "array of example objects or undefined", actualType(item.test_examples), "warning", "test_examples must be an array of objects with type, pattern, description, before, and after fields");
560
- } else {
561
- item.test_examples.forEach((example, exIdx) => {
562
- if (!example || typeof example !== "object" || !["added_assertion", "modified_assertion", "added_test_case", "added_pattern"].includes(example.type)) {
563
- addError(`test_impact[${index}].test_examples[${exIdx}].type`, "added_assertion|modified_assertion|added_test_case|added_pattern", actualType(example && example.type), "warning", "Each test_example must have a valid type");
564
- }
565
- if (!example || typeof example.pattern !== "string") {
566
- addError(`test_impact[${index}].test_examples[${exIdx}].pattern`, "string", actualType(example && example.pattern), "warning", "Each test_example must have a pattern string");
567
- }
568
- if (!example || typeof example.before !== "string" || typeof example.after !== "string") {
569
- addError(`test_impact[${index}].test_examples[${exIdx}]`, "before and after strings", "missing or invalid", "warning", "Each test_example must have before and after code snippets");
570
- }
571
- });
572
- }
573
- }
574
- });
575
- }
576
-
577
- // Validate suggested_allowlist (optional but if present, must be valid)
578
- if (artifact.suggested_allowlist) {
579
- if (typeof artifact.suggested_allowlist !== "object" || Array.isArray(artifact.suggested_allowlist)) {
580
- addError("suggested_allowlist", "object", actualType(artifact.suggested_allowlist), "warning", "suggested_allowlist must be an object with agent_patterns and validation_patterns arrays");
581
- } else {
582
- if (!Array.isArray(artifact.suggested_allowlist.agent_patterns)) {
583
- addError("suggested_allowlist.agent_patterns", "array of strings", actualType(artifact.suggested_allowlist.agent_patterns), "warning", "agent_patterns must be an array of glob pattern strings");
584
- } else if (!artifact.suggested_allowlist.agent_patterns.every((p) => typeof p === "string")) {
585
- addError("suggested_allowlist.agent_patterns", "array of strings", "array with non-strings", "warning", "All agent_patterns entries must be strings");
586
- }
587
- if (!Array.isArray(artifact.suggested_allowlist.validation_patterns)) {
588
- addError("suggested_allowlist.validation_patterns", "array of strings", actualType(artifact.suggested_allowlist.validation_patterns), "warning", "validation_patterns must be an array of glob pattern strings");
589
- } else if (!artifact.suggested_allowlist.validation_patterns.every((p) => typeof p === "string")) {
590
- addError("suggested_allowlist.validation_patterns", "array of strings", "array with non-strings", "warning", "All validation_patterns entries must be strings");
591
- }
592
- }
593
- }
594
- }
595
-
596
- if (errors.length) {
597
- const onlyTaskMissing = errors.length === 1 && errors[0].field === "task";
598
- const reasonCode = onlyTaskMissing ? "missing_required_fields" : "schema_mismatch";
599
- for (const error of errors) appendValidationFailure(reasonCode, error);
600
- fs.writeFileSync(errorLog, JSON.stringify({
601
- reason_code: reasonCode,
602
- details: summarize(errors),
603
- errors,
604
- }) + "\n");
605
- process.exit(1);
606
- }
607
- fs.writeFileSync(output, JSON.stringify(artifact, null, 2) + "\n");
608
- ' "$candidate_artifact" "$final_artifact" "$validation_error_file" 2>> /results/scouting-stderr.log
609
- }
610
482
  # Validate scouting artifact and emit structured reason code
611
483
  validate_scouting_artifact() {
612
484
  local candidate_artifact="$1"
@@ -1258,52 +1130,58 @@ run_expectation_mismatch_detector() {
1258
1130
  fi
1259
1131
  }
1260
1132
 
1133
+ resolve_allowlist_helper() {
1134
+ local script_dir="$1"
1135
+ local script_relative_helper="$script_dir/scripts/allowlist-helper.sh"
1136
+ local fallback_helper="${KASEKI_ALLOWLIST_HELPER_FALLBACK:-/app/scripts/allowlist-helper.sh}"
1137
+
1138
+ if [ -r "$script_relative_helper" ]; then
1139
+ printf '%s\n' "$script_relative_helper"
1140
+ return 0
1141
+ fi
1142
+
1143
+ if [ -r "$fallback_helper" ]; then
1144
+ printf '%s\n' "$fallback_helper"
1145
+ return 0
1146
+ fi
1147
+
1148
+ printf 'ERROR: Allowlist helper is not readable. Expected packaged helper at %s or fallback helper at %s. This worker image or mounted template is incomplete; rebuild the image or restore scripts/allowlist-helper.sh.\n' \
1149
+ "$script_relative_helper" \
1150
+ "$fallback_helper" >&2
1151
+ return 66
1152
+ }
1153
+
1261
1154
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1262
- ALLOWLIST_HELPER="$SCRIPT_DIR/scripts/allowlist-helper.sh"
1263
- if [ ! -r "$ALLOWLIST_HELPER" ] && [ -r /app/scripts/allowlist-helper.sh ]; then
1264
- ALLOWLIST_HELPER="/app/scripts/allowlist-helper.sh"
1155
+ ALLOWLIST_HELPER="$(resolve_allowlist_helper "$SCRIPT_DIR")"
1156
+ allowlist_helper_status=$?
1157
+ if [ "$allowlist_helper_status" -ne 0 ]; then
1158
+ exit "$allowlist_helper_status"
1159
+ fi
1160
+ SCOUTING_ALLOWLIST_HELPER="$SCRIPT_DIR/scripts/scouting-allowlist.js"
1161
+ if [ ! -r "$SCOUTING_ALLOWLIST_HELPER" ] && [ -r /app/scripts/scouting-allowlist.js ]; then
1162
+ SCOUTING_ALLOWLIST_HELPER="/app/scripts/scouting-allowlist.js"
1265
1163
  fi
1266
1164
  # shellcheck source=scripts/allowlist-helper.sh
1267
1165
  . "$ALLOWLIST_HELPER"
1166
+ if [ "${KASEKI_AGENT_HELPER_RESOLUTION_CHECK:-0}" = "1" ]; then
1167
+ build_allowlist_regex "${KASEKI_CHANGED_FILES_ALLOWLIST:-}" >/dev/null
1168
+ printf 'allowlist_helper=%s\n' "$ALLOWLIST_HELPER"
1169
+ exit 0
1170
+ fi
1268
1171
 
1269
1172
  derive_allowlist_from_scouting() {
1270
- local scouting_artifact agent_patterns validation_patterns
1173
+ local scouting_artifact
1271
1174
  scouting_artifact="${1:?missing scouting artifact path}"
1272
-
1175
+
1273
1176
  if [ ! -f "$scouting_artifact" ]; then
1274
1177
  printf 'derive_allowlist_from_scouting: scouting artifact not found: %s\n' "$scouting_artifact" >&2
1275
1178
  return 1
1276
1179
  fi
1277
-
1278
- # Extract patterns from scouting.json
1279
- agent_patterns="$(node -e "
1280
- try {
1281
- const fs = require('node:fs');
1282
- const artifact = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
1283
- if (artifact && artifact.suggested_allowlist && Array.isArray(artifact.suggested_allowlist.agent_patterns)) {
1284
- console.log(artifact.suggested_allowlist.agent_patterns.join(' '));
1285
- }
1286
- } catch (e) {
1287
- console.error('Error parsing scouting artifact:', e.message);
1288
- }
1289
- " "$scouting_artifact" 2>/dev/null)"
1290
-
1291
- validation_patterns="$(node -e "
1292
- try {
1293
- const fs = require('node:fs');
1294
- const artifact = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
1295
- if (artifact && artifact.suggested_allowlist && Array.isArray(artifact.suggested_allowlist.validation_patterns)) {
1296
- console.log(artifact.suggested_allowlist.validation_patterns.join(' '));
1297
- }
1298
- } catch (e) {
1299
- console.error('Error parsing scouting artifact:', e.message);
1300
- }
1301
- " "$scouting_artifact" 2>/dev/null)"
1302
-
1303
- printf '%s\n' "$agent_patterns"
1304
- printf '%s\n' "$validation_patterns"
1180
+
1181
+ node "$SCOUTING_ALLOWLIST_HELPER" derive "$scouting_artifact"
1305
1182
  }
1306
1183
 
1184
+
1307
1185
  validate_allowlist_patterns() {
1308
1186
  local patterns_str test_regex
1309
1187
  patterns_str="${1:-}"
@@ -4542,6 +4420,14 @@ Determine if the agent successfully met the requirements specified in the goal-s
4542
4420
 
4543
4421
  For each dimension, provide evidence by citing specific file locations, test names, or validation results.
4544
4422
 
4423
+ ## Success Criteria Assessment Contract
4424
+
4425
+ Use this contract when reading the goal-setting artifact:
4426
+
4427
+ - GOAL_CHECK_CONTRACT_PER_CRITERION_SMART: Assess each success criterion or acceptance criterion independently against all five SMART dimensions (Specific, Measurable, Achievable, Relevant, Time-bound), not just the overall goal.
4428
+ - GOAL_CHECK_CONTRACT_EVIDENCE_REQUIRED: For every met or unmet criterion assessment, cite concrete evidence from the diff, changed files, tests, validation results, or other listed artifacts.
4429
+ - GOAL_CHECK_CONTRACT_MEASURABLE_ACCEPTANCE_CRITERIA: Treat measurable acceptance criteria as observable pass/fail conditions; flag vague goals or intent statements as insufficient unless the implementation evidence maps them to concrete, verifiable outcomes.
4430
+
4545
4431
  ## Evidence Requirements
4546
4432
 
4547
4433
  Evidence must be SPECIFIC and VERIFIABLE. Reference concrete artifacts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanautomation/kaseki-agent",
3
- "version": "1.64.0",
3
+ "version": "1.64.1",
4
4
  "description": "Admin/helper/doctor toolbox and local API client for Kaseki diagnostics, setup, and API-backed coding-agent task workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,9 +8,10 @@
8
8
  * node collect-feedback.js run-evaluation <instance_name> <run_evaluation_json> <metadata_json>
9
9
  */
10
10
 
11
- const fs = require('fs');
11
+ import fs from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
12
13
 
13
- function parseJson(filePath) {
14
+ export function parseJson(filePath) {
14
15
  try {
15
16
  if (!filePath || !fs.existsSync(filePath)) {
16
17
  return {};
@@ -22,7 +23,7 @@ function parseJson(filePath) {
22
23
  }
23
24
  }
24
25
 
25
- function collectGoalCheckFeedback(instanceName, goalSettingPath, goalCheckPath, metadataPath) {
26
+ export function collectGoalCheckFeedback(instanceName, goalSettingPath, goalCheckPath, metadataPath) {
26
27
  const goalSetting = parseJson(goalSettingPath);
27
28
  const goalCheck = parseJson(goalCheckPath);
28
29
  const metadata = parseJson(metadataPath);
@@ -67,7 +68,7 @@ function collectGoalCheckFeedback(instanceName, goalSettingPath, goalCheckPath,
67
68
  return feedback;
68
69
  }
69
70
 
70
- function collectRunEvaluationFeedback(instanceName, runEvaluationPath, metadataPath) {
71
+ export function collectRunEvaluationFeedback(instanceName, runEvaluationPath, metadataPath) {
71
72
  const runEvaluation = parseJson(runEvaluationPath);
72
73
  const metadata = parseJson(metadataPath);
73
74
 
@@ -88,7 +89,7 @@ function collectRunEvaluationFeedback(instanceName, runEvaluationPath, metadataP
88
89
  return feedback;
89
90
  }
90
91
 
91
- function extractOutcomes(metadata) {
92
+ export function extractOutcomes(metadata) {
92
93
  return {
93
94
  validation_passed: metadata.validation_passed === true,
94
95
  coding_attempts: metadata.coding_attempts || 1,
@@ -97,7 +98,7 @@ function extractOutcomes(metadata) {
97
98
  };
98
99
  }
99
100
 
100
- function computeCorrelationNotes(qualityScore, verdict, outcomes) {
101
+ export function computeCorrelationNotes(qualityScore, verdict, outcomes) {
101
102
  const notes = [];
102
103
 
103
104
  if (qualityScore >= 85 && !verdict.met) {
@@ -116,14 +117,14 @@ function computeCorrelationNotes(qualityScore, verdict, outcomes) {
116
117
  return notes;
117
118
  }
118
119
 
119
- function parseStageValues(stageValues) {
120
+ export function parseStageValues(stageValues) {
120
121
  return stageValues.map((s) => ({
121
122
  stage: s.stage || 'unknown',
122
123
  value: s.value || 'unknown',
123
124
  }));
124
125
  }
125
126
 
126
- function parseImprovements(improvements) {
127
+ export function parseImprovements(improvements) {
127
128
  return improvements.map((imp) => ({
128
129
  category: imp.category || 'unknown',
129
130
  priority: imp.priority || 'medium',
@@ -164,4 +165,6 @@ function main() {
164
165
  console.log(JSON.stringify(feedback));
165
166
  }
166
167
 
167
- main();
168
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
169
+ main();
170
+ }
@@ -256,29 +256,46 @@ run_checkout_freshness_probe() {
256
256
  # Phase 4: Use parallel privilege tool testing when running as root
257
257
  # Runs setpriv, runuser, and sudo in parallel; returns on first success
258
258
  # This reduces probe time from ~6 seconds (sequential 3×2s timeouts) to ~2 seconds (first success)
259
+ local probe_exit_status=0
259
260
  if [ "$(id -u)" -eq "$KASEKI_CONTAINER_UID" ] && [ "$(id -g)" -eq "$KASEKI_CONTAINER_GID" ]; then
260
- "${probe_command[@]}" >/dev/null 2>"$stderr_file" || true
261
+ if "${probe_command[@]}" >/dev/null 2>"$stderr_file"; then
262
+ probe_exit_status=0
263
+ else
264
+ probe_exit_status=$?
265
+ fi
261
266
  elif [ "$(id -u)" -eq 0 ]; then
262
267
  # Phase 4: Parallel privilege tool testing
263
- run_privilege_tools_parallel "$checkout_dir" "$stderr_file" "$resolved_user_name" "$resolved_group_name" "${probe_command[@]}" || true
268
+ if run_privilege_tools_parallel "$checkout_dir" "$stderr_file" "$resolved_user_name" "$resolved_group_name" "${probe_command[@]}"; then
269
+ probe_exit_status=0
270
+ else
271
+ probe_exit_status=$?
272
+ fi
264
273
  else
265
- "${probe_command[@]}" >/dev/null 2>"$stderr_file" || true
274
+ if "${probe_command[@]}" >/dev/null 2>"$stderr_file"; then
275
+ probe_exit_status=0
276
+ else
277
+ probe_exit_status=$?
278
+ fi
266
279
  fi
267
280
 
268
- if [ -s "$stderr_file" ]; then
281
+ if [ "$probe_exit_status" -eq 0 ]; then
282
+ probe_status="ok"
283
+ probe_detail="Checkout freshness probe passed for ${checkout_dir} as UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID}."
284
+ else
269
285
  probe_status="failed"
270
286
  local stderr_tail
271
- stderr_tail="$(tail -n 1 "$stderr_file" | tr -d '\r')"
272
- if printf '%s' "$stderr_tail" | grep -Eiq 'unknown user|unknown group|no passwd entry|user .* does not exist|group .* does not exist|sudo: .*unknown|sudo: .*invalid|runuser: .*does not exist|runuser: user .* does not exist|runuser: group .* does not exist|unable to initialize policy plugin|unable to set user context|timed out'; then
273
- probe_detail="Checkout freshness probe failed: probe could not impersonate UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID} due to host user/group mapping, privilege-tool configuration issue, or timeout: ${stderr_tail}"
274
- probe_remediation="Configure a valid host method to run commands as UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID} (or ensure passwd/group mappings exist for that UID/GID), then rerun ./scripts/kaseki-setup-host.sh --fix. If the issue is timeout, try increasing KASEKI_PRIV_TOOL_TIMEOUT."
287
+ if [ -s "$stderr_file" ]; then
288
+ stderr_tail="$(tail -n 1 "$stderr_file" | tr -d '\r')"
289
+ else
290
+ stderr_tail="probe command exited with status ${probe_exit_status} without stderr output"
291
+ fi
292
+ if printf '%s' "$stderr_tail" | grep -Eiq 'unknown user|unknown group|no passwd entry|user .* does not exist|group .* does not exist|sudo: .*unknown|sudo: .*invalid|runuser: .*does not exist|runuser: user .* does not exist|runuser: group .* does not exist|unable to initialize policy plugin|error initializing audit plugin|sudoers_audit|unable to set user context|timed out|no usable privilege tool'; then
293
+ probe_detail="Checkout freshness probe failed: probe could not impersonate UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID} due to host user/group mapping, host privilege-tool configuration (including sudo policy/audit plugins), or timeout: ${stderr_tail}"
294
+ probe_remediation="Fix host privilege-tool configuration for sudo/policy/audit plugins (for example sudoers_audit), or configure another valid host method to run commands as UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID} (and ensure passwd/group mappings exist for that UID/GID), then rerun ./scripts/kaseki-setup-host.sh --fix. If the issue is timeout, try increasing KASEKI_PRIV_TOOL_TIMEOUT."
275
295
  else
276
296
  probe_detail="Checkout freshness probe failed when running git metadata access as UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID}: ${stderr_tail}"
277
297
  probe_remediation="Fix ownership/permissions so ${checkout_dir} and ${checkout_dir}/.git are readable by UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID}."
278
298
  fi
279
- else
280
- probe_status="ok"
281
- probe_detail="Checkout freshness probe passed for ${checkout_dir} as UID:GID ${KASEKI_CONTAINER_UID}:${KASEKI_CONTAINER_GID}."
282
299
  fi
283
300
  cleanup_probe_stderr_file
284
301
 
@@ -352,71 +369,112 @@ run_privilege_tools_parallel() {
352
369
  local resolved_group_name="$4"
353
370
  shift 4
354
371
  local probe_command=("$@")
355
-
372
+ : >"$stderr_file"
373
+
356
374
  local temp_dir
357
375
  temp_dir=$(mktemp -d)
358
- export temp_dir # Make accessible to trap handler in subshell contexts
359
376
  local success_marker="$temp_dir/success"
360
377
  local pids=()
361
-
378
+ local failure_stderr_files=()
379
+ local setpriv_stderr="$temp_dir/setpriv.stderr"
380
+ local runuser_stderr="$temp_dir/runuser.stderr"
381
+ local sudo_stderr="$temp_dir/sudo.stderr"
382
+
362
383
  cleanup_parallel() {
363
- # shellcheck disable=SC2317
364
- rm -rf "$temp_dir"
384
+ rm -rf "${temp_dir:-}"
365
385
  }
366
- trap cleanup_parallel EXIT
367
-
386
+
387
+ stop_parallel_jobs() {
388
+ local pid
389
+ for pid in "${pids[@]}"; do
390
+ kill "$pid" 2>/dev/null || true
391
+ done
392
+ for pid in "${pids[@]}"; do
393
+ wait "$pid" 2>/dev/null || true
394
+ done
395
+ }
396
+
397
+ copy_selected_failure_stderr() {
398
+ local candidate
399
+ for candidate in "${failure_stderr_files[@]}"; do
400
+ if [ -s "$candidate" ]; then
401
+ cp "$candidate" "$stderr_file"
402
+ return 0
403
+ fi
404
+ done
405
+ printf 'no usable privilege tool succeeded for %s\n' "$checkout_dir" >"$stderr_file"
406
+ }
407
+
368
408
  # Test 1: setpriv (fastest, preferred)
369
409
  if [ "$(id -u)" -eq 0 ] && command -v setpriv >/dev/null 2>&1; then
410
+ failure_stderr_files+=("$setpriv_stderr")
370
411
  (
371
- timeout "$KASEKI_PRIV_TOOL_TIMEOUT" setpriv --reuid "$KASEKI_CONTAINER_UID" --regid "$KASEKI_CONTAINER_GID" --clear-groups -- "${probe_command[@]}" >/dev/null 2>"$stderr_file" && touch "$success_marker"
412
+ if timeout "$KASEKI_PRIV_TOOL_TIMEOUT" setpriv --reuid "$KASEKI_CONTAINER_UID" --regid "$KASEKI_CONTAINER_GID" --clear-groups -- "${probe_command[@]}" >/dev/null 2>"$setpriv_stderr"; then
413
+ touch "$success_marker"
414
+ fi
372
415
  ) &
373
416
  pids+=("$!")
374
417
  fi
375
-
418
+
376
419
  # Test 2: runuser (if resolved user/group available)
377
420
  if [ "$(id -u)" -eq 0 ] && command -v runuser >/dev/null 2>&1 && [ -n "$resolved_user_name" ] && [ -n "$resolved_group_name" ]; then
421
+ failure_stderr_files+=("$runuser_stderr")
378
422
  (
379
- timeout "$KASEKI_PRIV_TOOL_TIMEOUT" runuser -u "$resolved_user_name" -g "$resolved_group_name" -- "${probe_command[@]}" >/dev/null 2>"$stderr_file" && touch "$success_marker"
423
+ if timeout "$KASEKI_PRIV_TOOL_TIMEOUT" runuser -u "$resolved_user_name" -g "$resolved_group_name" -- "${probe_command[@]}" >/dev/null 2>"$runuser_stderr"; then
424
+ touch "$success_marker"
425
+ fi
380
426
  ) &
381
427
  pids+=("$!")
382
428
  fi
383
-
429
+
384
430
  # Test 3: sudo (fallback, slowest)
385
431
  if [ "$(id -u)" -eq 0 ] && command -v sudo >/dev/null 2>&1; then
432
+ failure_stderr_files+=("$sudo_stderr")
386
433
  (
387
434
  if [ -n "$resolved_user_name" ] && [ -n "$resolved_group_name" ]; then
388
- timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "$resolved_user_name" -g "$resolved_group_name" -- "${probe_command[@]}" >/dev/null 2>"$stderr_file"
435
+ timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "$resolved_user_name" -g "$resolved_group_name" -- "${probe_command[@]}" >/dev/null 2>"$sudo_stderr"
389
436
  elif [ -n "$resolved_user_name" ]; then
390
- timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "$resolved_user_name" -- "${probe_command[@]}" >/dev/null 2>"$stderr_file"
437
+ timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "$resolved_user_name" -- "${probe_command[@]}" >/dev/null 2>"$sudo_stderr"
391
438
  else
392
- timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "#${KASEKI_CONTAINER_UID}" -g "${KASEKI_CONTAINER_GID}" -- "${probe_command[@]}" >/dev/null 2>"$stderr_file"
439
+ timeout "$KASEKI_PRIV_TOOL_TIMEOUT" sudo -u "#${KASEKI_CONTAINER_UID}" -g "${KASEKI_CONTAINER_GID}" -- "${probe_command[@]}" >/dev/null 2>"$sudo_stderr"
393
440
  fi && touch "$success_marker"
394
441
  ) &
395
442
  pids+=("$!")
396
443
  fi
397
-
444
+
445
+ if [ "${#pids[@]}" -eq 0 ]; then
446
+ copy_selected_failure_stderr
447
+ cleanup_parallel
448
+ return 1
449
+ fi
450
+
398
451
  # Wait for any process to succeed (check success marker while processes run)
399
- local timeout_elapsed=0
400
- local max_wait=$((KASEKI_PRIV_TOOL_TIMEOUT + 1))
401
- while [ $timeout_elapsed -lt $max_wait ]; do
452
+ local wait_attempt=0
453
+ local max_wait_attempts=$(( (KASEKI_PRIV_TOOL_TIMEOUT + 1) * 10 ))
454
+ while [ "$wait_attempt" -lt "$max_wait_attempts" ]; do
402
455
  if [ -f "$success_marker" ]; then
403
- # Kill remaining background processes
404
- for pid in "${pids[@]}"; do
405
- kill "$pid" 2>/dev/null || true
406
- done
456
+ stop_parallel_jobs
457
+ cleanup_parallel
407
458
  return 0
408
459
  fi
409
460
  sleep 0.1
410
- timeout_elapsed=$(( timeout_elapsed + 1 ))
461
+ wait_attempt=$(( wait_attempt + 1 ))
411
462
  done
412
-
463
+
413
464
  # Wait for all processes to complete
414
465
  for pid in "${pids[@]}"; do
415
466
  wait "$pid" 2>/dev/null || true
416
467
  done
417
-
418
- # Check if any succeeded
419
- [ -f "$success_marker" ] && return 0
468
+
469
+ # Check if any succeeded. On success, leave the shared stderr empty so failed
470
+ # parallel attempts cannot turn a successful privilege probe into a failure.
471
+ if [ -f "$success_marker" ]; then
472
+ cleanup_parallel
473
+ return 0
474
+ fi
475
+
476
+ copy_selected_failure_stderr
477
+ cleanup_parallel
420
478
  return 1
421
479
  }
422
480
 
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const DEFAULT_CHANGED_FILES_ALLOWLIST = 'src/lib/parser.ts tests/parser.validation.ts';
7
+ export const DEFAULT_VALIDATION_ALLOWLIST = '';
8
+
9
+ export function actualType(value) {
10
+ if (value === null) return 'null';
11
+ if (Array.isArray(value)) return 'array';
12
+ return typeof value;
13
+ }
14
+
15
+ function summarize(errors) {
16
+ const critical = errors.filter((error) => error.severity === 'critical').length;
17
+ const warning = errors.filter((error) => error.severity === 'warning').length;
18
+ const counts = [];
19
+ if (critical) counts.push(`${critical} critical`);
20
+ if (warning) counts.push(`${warning} warning`);
21
+ const fields = errors.slice(0, 2).map((error) => error.field).join(', ');
22
+ const suffix = errors.length > 2 ? `, +${errors.length - 2} more` : '';
23
+ return `${counts.join(', ')} scouting validation ${errors.length === 1 ? 'error' : 'errors'}: ${fields}${suffix}`;
24
+ }
25
+
26
+ function appendJsonl(file, value) {
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.appendFileSync(file, JSON.stringify(value) + '\n');
29
+ }
30
+
31
+ function readArtifact(inputPath) {
32
+ return JSON.parse(fs.readFileSync(inputPath, 'utf8'));
33
+ }
34
+
35
+ export function validateScoutingArtifactObject(artifact) {
36
+ const errors = [];
37
+ const addError = (field, expected, actual, severity, suggestion) => {
38
+ errors.push({ field, expected, actual, severity, suggestion });
39
+ };
40
+ const arrayKeys = ['requirements', 'relevant_files', 'observations', 'plan', 'validation', 'risks', 'test_impact'];
41
+
42
+ if (!artifact || Array.isArray(artifact) || typeof artifact !== 'object') {
43
+ addError('root', 'object', actualType(artifact), 'critical', 'Scouting artifact must be a JSON object, not an array/null/primitive');
44
+ } else {
45
+ if (typeof artifact.task !== 'string' || !artifact.task.trim()) {
46
+ addError('task', 'non-empty string', typeof artifact.task === 'string' ? 'empty string' : actualType(artifact.task), 'critical', 'task must be a non-empty string describing the requested work');
47
+ }
48
+ for (const key of arrayKeys) {
49
+ if (!Array.isArray(artifact[key])) {
50
+ addError(key, 'array', actualType(artifact[key]), 'critical', `${key} must be an array in the scouting handoff`);
51
+ }
52
+ }
53
+ if (Array.isArray(artifact.relevant_files)) {
54
+ artifact.relevant_files.forEach((item, index) => {
55
+ if (!item || typeof item.path !== 'string' || typeof item.reason !== 'string') {
56
+ addError(`relevant_files[${index}]`, 'object with string path and string reason', actualType(item), 'warning', 'Each relevant_files entry must include path and reason strings');
57
+ }
58
+ });
59
+ }
60
+ if (Array.isArray(artifact.test_impact)) {
61
+ artifact.test_impact.forEach((item, index) => {
62
+ if (!item || typeof item.path !== 'string' || !item.path.trim() || typeof item.reason !== 'string' || !item.reason.trim()) {
63
+ addError(`test_impact[${index}]`, 'object with non-empty string path and non-empty string reason', actualType(item), 'critical', 'Each test_impact entry must include the impacted test path and expectation reason strings');
64
+ }
65
+ if (item && typeof item === 'object' && item.test_examples !== undefined) {
66
+ if (!Array.isArray(item.test_examples)) {
67
+ addError(`test_impact[${index}].test_examples`, 'array of example objects or undefined', actualType(item.test_examples), 'warning', 'test_examples must be an array of objects with type, pattern, description, before, and after fields');
68
+ } else {
69
+ item.test_examples.forEach((example, exIdx) => {
70
+ if (!example || typeof example !== 'object' || !['added_assertion', 'modified_assertion', 'added_test_case', 'added_pattern'].includes(example.type)) {
71
+ addError(`test_impact[${index}].test_examples[${exIdx}].type`, 'added_assertion|modified_assertion|added_test_case|added_pattern', actualType(example && example.type), 'warning', 'Each test_example must have a valid type');
72
+ }
73
+ if (!example || typeof example.pattern !== 'string') {
74
+ addError(`test_impact[${index}].test_examples[${exIdx}].pattern`, 'string', actualType(example && example.pattern), 'warning', 'Each test_example must have a pattern string');
75
+ }
76
+ if (!example || typeof example.before !== 'string' || typeof example.after !== 'string') {
77
+ addError(`test_impact[${index}].test_examples[${exIdx}]`, 'before and after strings', 'missing or invalid', 'warning', 'Each test_example must have before and after code snippets');
78
+ }
79
+ });
80
+ }
81
+ }
82
+ });
83
+ }
84
+
85
+ if (artifact.suggested_allowlist) {
86
+ if (typeof artifact.suggested_allowlist !== 'object' || Array.isArray(artifact.suggested_allowlist)) {
87
+ addError('suggested_allowlist', 'object', actualType(artifact.suggested_allowlist), 'warning', 'suggested_allowlist must be an object with agent_patterns and validation_patterns arrays');
88
+ } else {
89
+ if (!Array.isArray(artifact.suggested_allowlist.agent_patterns)) {
90
+ addError('suggested_allowlist.agent_patterns', 'array of strings', actualType(artifact.suggested_allowlist.agent_patterns), 'warning', 'agent_patterns must be an array of glob pattern strings');
91
+ } else if (!artifact.suggested_allowlist.agent_patterns.every((p) => typeof p === 'string')) {
92
+ addError('suggested_allowlist.agent_patterns', 'array of strings', 'array with non-strings', 'warning', 'All agent_patterns entries must be strings');
93
+ }
94
+ if (!Array.isArray(artifact.suggested_allowlist.validation_patterns)) {
95
+ addError('suggested_allowlist.validation_patterns', 'array of strings', actualType(artifact.suggested_allowlist.validation_patterns), 'warning', 'validation_patterns must be an array of glob pattern strings');
96
+ } else if (!artifact.suggested_allowlist.validation_patterns.every((p) => typeof p === 'string')) {
97
+ addError('suggested_allowlist.validation_patterns', 'array of strings', 'array with non-strings', 'warning', 'All validation_patterns entries must be strings');
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ if (errors.length) {
104
+ const onlyTaskMissing = errors.length === 1 && errors[0].field === 'task';
105
+ return {
106
+ status: 'rejected',
107
+ reason_code: onlyTaskMissing ? 'missing_required_fields' : 'schema_mismatch',
108
+ details: summarize(errors),
109
+ errors,
110
+ };
111
+ }
112
+
113
+ return { status: 'ok', reason_code: 'valid', details: 'artifact validation passed', errors: [] };
114
+ }
115
+
116
+ export function validateScoutingArtifact(inputPath, outputPath, options = {}) {
117
+ let artifact;
118
+ try {
119
+ artifact = readArtifact(inputPath);
120
+ } catch (err) {
121
+ const error = {
122
+ field: 'root',
123
+ expected: 'exactly one valid JSON object',
124
+ actual: err && err.message ? String(err.message) : 'JSON parse failed',
125
+ severity: 'critical',
126
+ suggestion: 'ensure exactly one valid JSON object is written to /results/scouting-candidate.json',
127
+ };
128
+ const result = { status: 'rejected', reason_code: 'malformed_json', details: summarize([error]), errors: [error] };
129
+ writeValidationArtifacts(result, options);
130
+ return result;
131
+ }
132
+
133
+ const result = validateScoutingArtifactObject(artifact);
134
+ if (result.status === 'ok' && outputPath) {
135
+ fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n');
136
+ }
137
+ writeValidationArtifacts(result, options);
138
+ return result;
139
+ }
140
+
141
+ function writeValidationArtifacts(result, options) {
142
+ const { errorLog, jsonlLog } = options;
143
+ if (result.status === 'ok') return;
144
+ if (jsonlLog) {
145
+ for (const error of result.errors) {
146
+ appendJsonl(jsonlLog, { timestamp: new Date().toISOString(), reason_code: result.reason_code, ...error });
147
+ }
148
+ }
149
+ if (errorLog) {
150
+ fs.writeFileSync(errorLog, JSON.stringify({ reason_code: result.reason_code, details: result.details, errors: result.errors }) + '\n');
151
+ }
152
+ }
153
+
154
+ export function deriveAllowlistFromScoutingArtifact(artifact) {
155
+ return {
156
+ agentAllowlist: artifact && artifact.suggested_allowlist && Array.isArray(artifact.suggested_allowlist.agent_patterns)
157
+ ? artifact.suggested_allowlist.agent_patterns.join(' ')
158
+ : '',
159
+ validationAllowlist: artifact && artifact.suggested_allowlist && Array.isArray(artifact.suggested_allowlist.validation_patterns)
160
+ ? artifact.suggested_allowlist.validation_patterns.join(' ')
161
+ : '',
162
+ };
163
+ }
164
+
165
+ export function deriveAllowlistFromScouting(inputPath) {
166
+ return deriveAllowlistFromScoutingArtifact(readArtifact(inputPath));
167
+ }
168
+
169
+ export function mergeAllowlists(scoutingPatterns = '', userPatterns = '') {
170
+ if (scoutingPatterns && userPatterns) return `${scoutingPatterns} ${userPatterns}`;
171
+ return scoutingPatterns || userPatterns || '';
172
+ }
173
+
174
+ export function deriveScoutingAllowlistOrDefault(inputPath, options = {}) {
175
+ const defaultChangedFilesAllowlist = options.defaultChangedFilesAllowlist ?? DEFAULT_CHANGED_FILES_ALLOWLIST;
176
+ const defaultValidationAllowlist = options.defaultValidationAllowlist ?? DEFAULT_VALIDATION_ALLOWLIST;
177
+ const validation = validateScoutingArtifact(inputPath, undefined, options);
178
+
179
+ if (validation.status !== 'ok') {
180
+ return {
181
+ validation,
182
+ agentAllowlist: defaultChangedFilesAllowlist,
183
+ validationAllowlist: defaultValidationAllowlist,
184
+ source: 'default_after_rejection',
185
+ };
186
+ }
187
+
188
+ const derived = deriveAllowlistFromScouting(inputPath);
189
+ return {
190
+ validation,
191
+ agentAllowlist: mergeAllowlists(derived.agentAllowlist, defaultChangedFilesAllowlist),
192
+ validationAllowlist: mergeAllowlists(derived.validationAllowlist, defaultValidationAllowlist),
193
+ source: derived.agentAllowlist || derived.validationAllowlist ? 'merged_scouting' : 'default_after_absent_suggestion',
194
+ };
195
+ }
196
+
197
+ function printJson(value) {
198
+ process.stdout.write(JSON.stringify(value));
199
+ }
200
+
201
+ function main(argv) {
202
+ const [command, ...args] = argv;
203
+ if (command === 'validate') {
204
+ const [inputPath, outputPath, errorLog, jsonlLog] = args;
205
+ const result = validateScoutingArtifact(inputPath, outputPath, { errorLog, jsonlLog });
206
+ printJson(result);
207
+ process.exitCode = result.status === 'ok' ? 0 : 1;
208
+ return;
209
+ }
210
+ if (command === 'derive') {
211
+ const result = deriveAllowlistFromScouting(args[0]);
212
+ process.stdout.write(`${result.agentAllowlist}\n${result.validationAllowlist}\n`);
213
+ return;
214
+ }
215
+ if (command === 'orchestrate') {
216
+ printJson(deriveScoutingAllowlistOrDefault(args[0], {
217
+ defaultChangedFilesAllowlist: args[1] ?? DEFAULT_CHANGED_FILES_ALLOWLIST,
218
+ defaultValidationAllowlist: args[2] ?? DEFAULT_VALIDATION_ALLOWLIST,
219
+ }));
220
+ return;
221
+ }
222
+ process.stderr.write('usage: scouting-allowlist.js <validate|derive|orchestrate> ...\n');
223
+ process.exitCode = 64;
224
+ }
225
+
226
+ const thisFile = fileURLToPath(import.meta.url);
227
+ if (process.argv[1] && path.resolve(process.argv[1]) === thisFile) {
228
+ main(process.argv.slice(2));
229
+ }