@kici-dev/agent 0.6.1 → 0.7.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.
Files changed (32) hide show
  1. package/dist/config.d.ts +38 -38
  2. package/dist/eval-runner.js +1866 -0
  3. package/dist/execution/dep-installer.d.ts +28 -7
  4. package/dist/execution/eval-context.d.ts +114 -0
  5. package/dist/execution/global-eval-types.d.ts +26 -0
  6. package/dist/execution/job-runner.d.ts +23 -75
  7. package/dist/execution/npm-registry-config.d.ts +6 -0
  8. package/dist/execution/rule-evaluator.d.ts +2 -1
  9. package/dist/execution/sandbox/bare-metal-sandbox.d.ts +6 -0
  10. package/dist/execution/sandbox/container-hardening.d.ts +8 -0
  11. package/dist/execution/sandbox/container-sandbox.d.ts +9 -0
  12. package/dist/execution/sandbox/eval-dispatch.d.ts +28 -0
  13. package/dist/execution/sandbox/eval-fork-runner.d.ts +43 -0
  14. package/dist/execution/sandbox/eval-runner.d.ts +21 -0
  15. package/dist/execution/sandbox/fork-runner.d.ts +23 -0
  16. package/dist/execution/sandbox/ipc-protocol.d.ts +82 -8
  17. package/dist/execution/sandbox/job-network.d.ts +91 -0
  18. package/dist/execution/sandbox/log-masker.d.ts +38 -0
  19. package/dist/execution/sandbox/types.d.ts +6 -0
  20. package/dist/execution/sandbox/workflow-runner.d.ts +1 -1
  21. package/dist/execution/source-packer.d.ts +4 -4
  22. package/dist/execution/source-restore.d.ts +28 -13
  23. package/dist/execution/workflow-loader.d.ts +16 -13
  24. package/dist/execution/yarnrc-berry-config.d.ts +6 -4
  25. package/dist/index.js +83 -40
  26. package/dist/provenance/statement-builder.d.ts +19 -8
  27. package/dist/server.js +1527 -1665
  28. package/dist/workflow-runner-bundle.js +1105 -184
  29. package/dist/workflow-runner.js +395 -149
  30. package/dist/ws/orchestrator-client.d.ts +4 -0
  31. package/package.json +6 -5
  32. package/sbom.spdx.json +66 -66
@@ -10,7 +10,8 @@ import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256,
10
10
  import { createTempScope, makeTempDir } from "@kici-dev/core/tmp";
11
11
  import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName, reservedEventNamePrefix } from "@kici-dev/engine";
12
12
  import { execFile, execFileSync } from "node:child_process";
13
- import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, isDynamicJobFn, isEventDefinition, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
13
+ import { isDynamicJobFn, isEventDefinition, isParallelGroup, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs } from "@kici-dev/sdk";
14
+ import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, normalizeApproval, normalizeCacheSpecs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk/internal";
14
15
  import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
15
16
  import { HOME_ANCHOR, REPO_ANCHOR, computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
16
17
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
@@ -30,6 +31,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
30
31
  import { promisify } from "node:util";
31
32
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
32
33
  import { parse, stringify } from "yaml";
34
+ import { COMPILE_SCHEMA_VERSION, collectSourceSymlinks, findKiciDir, hashKiciSourceTree, hashedSymlinkDriftNote } from "@kici-dev/core/kici-source-digest";
33
35
  var __defProp = Object.defineProperty;
34
36
  var __esmMin = (fn, res, err) => () => {
35
37
  if (err) throw err[0];
@@ -359,7 +361,12 @@ async function computeChangedFiles(workDir, event, auth) {
359
361
  * for a deferred attestation (no minted identity token yet). Marks
360
362
  * `attestationOrigin: 'deferred'` in the internal parameters. The caller
361
363
  * DSSE-signs the returned statement immediately and computes its statement hash
362
- * — the binding the later OIDC mint commits to (truth-contract property 2).
364
+ * — one of the two bindings the later OIDC mint commits to.
365
+ *
366
+ * Emits the same fields `buildProvenanceStatement` emits, so a statement frozen
367
+ * from an orchestrator-supplied context is field-for-field what a live mint
368
+ * would have produced. That is what lets the orchestrator cross-check the
369
+ * statement against its own run row before signing anything that commits to it.
363
370
  */
364
371
  function buildLocalProvenanceStatement(input) {
365
372
  const c = input.context;
@@ -373,11 +380,14 @@ function buildLocalProvenanceStatement(input) {
373
380
  predicate: {
374
381
  buildDefinition: {
375
382
  buildType: KICI_WORKFLOW_BUILD_TYPE,
376
- externalParameters: { workflow: {
377
- repository: c.repository,
378
- ref: c.ref,
379
- path: c.workflowRef
380
- } },
383
+ externalParameters: {
384
+ workflow: {
385
+ repository: c.repository,
386
+ ref: c.ref,
387
+ path: c.workflowRef
388
+ },
389
+ ...c.provider ? { provider: c.provider } : {}
390
+ },
381
391
  internalParameters: {
382
392
  ...c.sha ? { commit: c.sha } : {},
383
393
  runId: c.runId,
@@ -389,7 +399,7 @@ function buildLocalProvenanceStatement(input) {
389
399
  },
390
400
  runDetails: {
391
401
  builder: {
392
- id: `${c.issuer}/orchestrator/unknown`,
402
+ id: `${c.issuer}/orchestrator/${c.orchestratorId ?? "unknown"}`,
393
403
  version: input.builderVersions
394
404
  },
395
405
  metadata: {
@@ -1384,15 +1394,6 @@ function createArtifactsApi(workDir, transport, roots) {
1384
1394
  }
1385
1395
  //#endregion
1386
1396
  //#region src/execution/sandbox/log-masker.ts
1387
- /**
1388
- * Secret value masking for log lines.
1389
- *
1390
- * Replaces all occurrences of registered secret values with '***' in log output.
1391
- * Used by the workflow runner to prevent secret leaks in IPC log messages.
1392
- *
1393
- * Performance: Builds a single combined regex from all secret values, so each
1394
- * log line is scanned in a single pass (not O(secrets * lines)).
1395
- */
1396
1397
  /** Minimum length for a secret value to be maskable (avoids false positives). */
1397
1398
  const MIN_MASK_LENGTH = 3;
1398
1399
  /**
@@ -1423,18 +1424,38 @@ var LogMasker = class {
1423
1424
  * Authorization: Basic headers, base64-encoded config values).
1424
1425
  * Values are sorted by length descending so longer values are matched first
1425
1426
  * (prevents partial masking when one secret is a substring of another).
1427
+ *
1428
+ * Multi-line values additionally register each of their individual lines.
1429
+ * Log output is split into lines before it reaches the masker, so a value
1430
+ * containing a newline can never match as a whole — a PEM private key or a
1431
+ * kubeconfig would otherwise stream in clear text. Two consequences of the
1432
+ * per-line registration are deliberate:
1433
+ *
1434
+ * - `MIN_MASK_LENGTH` is 3, so short structural lines of a structured secret
1435
+ * are registered too. A `---` YAML separator or a bare `{` from a
1436
+ * service-account JSON is masked wherever it appears in that job's logs.
1437
+ * - PEM header and footer lines (`-----BEGIN OPENSSH PRIVATE KEY-----`) are
1438
+ * not secret on their own and are masked as a side effect.
1439
+ *
1440
+ * Both are strictly safer than leaking the body, and no heuristic separates a
1441
+ * structural line from a body line without risking the reverse mistake.
1426
1442
  */
1427
1443
  registerSecrets(secrets) {
1428
1444
  const seen = /* @__PURE__ */ new Set();
1429
1445
  const values = [];
1430
- for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
1431
- seen.add(value);
1432
- values.push(value);
1433
- const b64 = Buffer.from(value).toString("base64");
1446
+ const add = (candidate) => {
1447
+ if (candidate.length < MIN_MASK_LENGTH || seen.has(candidate)) return;
1448
+ seen.add(candidate);
1449
+ values.push(candidate);
1450
+ const b64 = Buffer.from(candidate).toString("base64");
1434
1451
  if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
1435
1452
  seen.add(b64);
1436
1453
  values.push(b64);
1437
1454
  }
1455
+ };
1456
+ for (const value of Object.values(secrets)) {
1457
+ add(value);
1458
+ if (value.includes("\n")) for (const rawLine of value.split("\n")) add(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
1438
1459
  }
1439
1460
  if (values.length === 0) {
1440
1461
  this.pattern = null;
@@ -1460,6 +1481,57 @@ var LogMasker = class {
1460
1481
  return this.pattern !== null;
1461
1482
  }
1462
1483
  };
1484
+ /**
1485
+ * Create a LogMasker initialized with all secret values from the request.
1486
+ *
1487
+ * Collects values from both flat secrets and all namespaced context secrets,
1488
+ * deduplicating before registration.
1489
+ *
1490
+ * Both the runner child and the agent-side fork runner build a masker from the
1491
+ * same request, so the crash tail the agent assembles from the child's stderr is
1492
+ * masked with the same value set the child used for its own log lines.
1493
+ */
1494
+ function createSecretMasker(request) {
1495
+ const masker = new LogMasker();
1496
+ const allSecrets = {};
1497
+ if (request.secrets) Object.assign(allSecrets, request.secrets);
1498
+ if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
1499
+ masker.registerSecrets(allSecrets);
1500
+ return masker;
1501
+ }
1502
+ /**
1503
+ * Mask every operator-visible text field of an outbound runner message.
1504
+ *
1505
+ * Each message type carrying free text is named here, so a new text-bearing
1506
+ * message type is a visible omission rather than a silent leak. `step.complete`
1507
+ * error text and the `job.complete` failure reason are persisted on the step and
1508
+ * run rows the dashboard renders, so they need the same masking `log.line` gets.
1509
+ *
1510
+ * Returns the message unchanged when no secrets are registered.
1511
+ */
1512
+ function maskMessageText(msg, masker) {
1513
+ if (!masker.hasSecrets()) return msg;
1514
+ switch (msg.type) {
1515
+ case "log.line": return {
1516
+ ...msg,
1517
+ line: masker.mask(msg.line)
1518
+ };
1519
+ case "step.complete": return msg.error ? {
1520
+ ...msg,
1521
+ error: {
1522
+ ...msg.error,
1523
+ message: masker.mask(msg.error.message)
1524
+ }
1525
+ } : msg;
1526
+ case "job.complete": {
1527
+ const masked = { ...msg };
1528
+ if (masked.error !== void 0) masked.error = masker.mask(masked.error);
1529
+ if (masked.droppedJobs) masked.droppedJobs = masked.droppedJobs.map((j) => masker.mask(j));
1530
+ return masked;
1531
+ }
1532
+ default: return msg;
1533
+ }
1534
+ }
1463
1535
  //#endregion
1464
1536
  //#region src/execution/sandbox/env-delta.ts
1465
1537
  /**
@@ -1639,18 +1711,23 @@ async function executeHook(opts) {
1639
1711
  step_type: `hook:${hookType}`
1640
1712
  });
1641
1713
  const startTime = Date.now();
1714
+ const abortController = new AbortController();
1715
+ let rejectTimeout = () => {};
1716
+ const timeoutRace = new Promise((_, reject) => {
1717
+ rejectTimeout = reject;
1718
+ });
1719
+ const timeoutId = setTimeout(() => {
1720
+ rejectTimeout(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
1721
+ abortController.abort();
1722
+ }, timeoutMs);
1642
1723
  const mergedCtx = {
1643
1724
  ...stepContext,
1644
- outcome
1725
+ outcome,
1726
+ signal: AbortSignal.any([...stepContext.signal ? [stepContext.signal] : [], abortController.signal]),
1727
+ ...typeof stepContext.$ === "function" ? { $: stepContext.$({ signal: abortController.signal }) } : {}
1645
1728
  };
1646
- const abortController = new AbortController();
1647
- const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
1648
1729
  try {
1649
- await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
1650
- abortController.signal.addEventListener("abort", () => {
1651
- reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
1652
- });
1653
- })]);
1730
+ await Promise.race([normalized.run(mergedCtx), timeoutRace]);
1654
1731
  clearTimeout(timeoutId);
1655
1732
  sendIpc({
1656
1733
  type: "step.complete",
@@ -1925,12 +2002,12 @@ const STEP_FAILURE_LOG_MAX_CHARS = 8192;
1925
2002
  * One consequence to know: a `$({ quiet: true })` command's output is kept out
1926
2003
  * of the log by the `verbose` gate in `streaming-zx-log.ts`, but when such a
1927
2004
  * command FAILS, zx packs its captured output into the thrown error's message —
1928
- * which this function then writes to the log. That text is already persisted
1929
- * unmasked on `step.complete` (the step row the dashboard renders), so the copy
1930
- * written here is the more protected of the two, and surfacing it is the whole
1931
- * point: a quiet command that fails is exactly the failure an operator cannot
1932
- * otherwise diagnose. Registered secret values are masked; anything the masker
1933
- * has never been told about is not.
2005
+ * which this function then writes to the log. Surfacing it is the whole point:
2006
+ * a quiet command that fails is exactly the failure an operator cannot
2007
+ * otherwise diagnose. The same text also travels on `step.complete` for the step
2008
+ * row the dashboard renders, and both copies pass the masker `maskMessageText`
2009
+ * masks `step.complete.error.message` alongside `log.line`. Registered secret
2010
+ * values are masked; anything the masker has never been told about is not.
1934
2011
  */
1935
2012
  function emitStepFailureLog(stepName, stepIndex, message, sendFn) {
1936
2013
  const prefix = `[kici] Step '${stepName}' failed: `;
@@ -1967,16 +2044,29 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1967
2044
  });
1968
2045
  const startTime = Date.now();
1969
2046
  const abortController = new AbortController();
1970
- const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
2047
+ let timedOut = false;
2048
+ let rejectTimeout = () => {};
2049
+ const timeoutRace = new Promise((_, reject) => {
2050
+ rejectTimeout = reject;
2051
+ });
2052
+ const timeoutId = setTimeout(() => {
2053
+ timedOut = true;
2054
+ rejectTimeout(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
2055
+ abortController.abort();
2056
+ opts.abortStep?.(stepIndex);
2057
+ }, timeoutMs);
1971
2058
  const stepAbortSignal = opts.getStepAbortSignal?.(stepIndex);
2059
+ const stepPromise = runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts);
2060
+ let stepSettled = false;
2061
+ const stepSettledPromise = stepPromise.then(() => {
2062
+ stepSettled = true;
2063
+ }, () => {
2064
+ stepSettled = true;
2065
+ });
1972
2066
  try {
1973
2067
  const phase = await Promise.race([
1974
- runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts),
1975
- new Promise((_, reject) => {
1976
- abortController.signal.addEventListener("abort", () => {
1977
- reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
1978
- });
1979
- }),
2068
+ stepPromise,
2069
+ timeoutRace,
1980
2070
  new Promise((_, reject) => {
1981
2071
  if (!jobDeadlineSignal) return;
1982
2072
  if (jobDeadlineSignal.aborted) {
@@ -1993,7 +2083,10 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1993
2083
  reject(new StepCancelledError(step.name));
1994
2084
  return;
1995
2085
  }
1996
- stepAbortSignal.addEventListener("abort", () => reject(new StepCancelledError(step.name)));
2086
+ stepAbortSignal.addEventListener("abort", () => {
2087
+ if (timedOut) return;
2088
+ reject(new StepCancelledError(step.name));
2089
+ });
1997
2090
  })
1998
2091
  ]);
1999
2092
  clearTimeout(timeoutId);
@@ -2025,6 +2118,15 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
2025
2118
  clearTimeout(timeoutId);
2026
2119
  const durationMs = Date.now() - startTime;
2027
2120
  const error = e instanceof Error ? e : new Error(String(e));
2121
+ if (timedOut && !stepSettled) {
2122
+ await Promise.race([stepSettledPromise, delayUnref(STEP_ABORT_GRACE_MS)]);
2123
+ if (!stepSettled) sendFn({
2124
+ type: "log.line",
2125
+ stepIndex,
2126
+ line: `[timeout] Step '${step.name}' did not stop within ${STEP_ABORT_GRACE_MS}ms of its abort signal; continuing without it.`,
2127
+ stream: LogStream.enum.stderr
2128
+ });
2129
+ }
2028
2130
  if (e instanceof StepCancelledError) {
2029
2131
  const secretsAccessed = getSecretsAccessLog?.(stepIndex);
2030
2132
  emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
@@ -2073,6 +2175,17 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
2073
2175
  }
2074
2176
  }
2075
2177
  /**
2178
+ * How long a timed-out step is given to unwind after its abort signal fires,
2179
+ * before the loop reports that the step ignored it and moves on.
2180
+ */
2181
+ const STEP_ABORT_GRACE_MS = 2e3;
2182
+ /** A timer-backed delay that never keeps the process alive on its own. */
2183
+ function delayUnref(ms) {
2184
+ return new Promise((resolve) => {
2185
+ setTimeout(resolve, ms).unref?.();
2186
+ });
2187
+ }
2188
+ /**
2076
2189
  * Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
2077
2190
  * the step performed. Called from both the success and failure paths so the
2078
2191
  * orchestrator's audit trail records every mount regardless of step outcome.
@@ -3365,10 +3478,12 @@ function redactNpmOutput(input, tokens) {
3365
3478
  *
3366
3479
  * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
3367
3480
  * (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
3368
- * workflow-loader work unchanged. `enableScripts: false` (when a private
3369
- * registry is configured) keeps dependency lifecycle scripts from seeing the
3370
- * synthesized token env vars — the same security model as npm/pnpm/classic
3371
- * `--ignore-scripts`.
3481
+ * workflow-loader work unchanged. `enableScripts: false` keeps dependency
3482
+ * lifecycle scripts from running at all for every install, not only one
3483
+ * against a private registry — the same security model as npm/pnpm/classic
3484
+ * `--ignore-scripts`. An operator opts back in with
3485
+ * `KICI_ALLOW_INSTALL_SCRIPTS=true`, which arrives here as
3486
+ * `ignoreScripts: false`.
3372
3487
  *
3373
3488
  * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
3374
3489
  * shapes as the npm overlay so `dep-installer` can pick either by flavor.
@@ -3415,8 +3530,8 @@ async function applyYarnrcBerryConfig(args) {
3415
3530
  };
3416
3531
  const tokenEnv = {};
3417
3532
  const tokensForRedaction = [];
3533
+ if (args.ignoreScripts !== false) merged.enableScripts = false;
3418
3534
  if (hasPrivateRegistry) {
3419
- merged.enableScripts = false;
3420
3535
  const npmScopes = { ...doc.npmScopes ?? {} };
3421
3536
  for (let i = 0; i < registries.length; i++) {
3422
3537
  const reg = registries[i];
@@ -3725,9 +3840,11 @@ function isAbsoluteRel(rel) {
3725
3840
  * Security: the install runs with an isolated per-invocation cache/store
3726
3841
  * directory to prevent cache poisoning across build jobs — a malicious
3727
3842
  * package.json in one repo cannot taint the cache used by subsequent builds.
3728
- * The same pressure rules out letting lifecycle scripts see synthesized auth
3729
- * env vars the install runs with `--ignore-scripts` whenever a private
3730
- * registry is configured.
3843
+ * The install runs with `--ignore-scripts` for every package manager. A
3844
+ * lifecycle script in a committed `package.json` is customer code the agent
3845
+ * never agreed to execute: it would run wherever the install runs, which for a
3846
+ * step-child install is the process holding the job's secrets. Operators who
3847
+ * genuinely need it set `KICI_ALLOW_INSTALL_SCRIPTS=true` on the agent.
3731
3848
  */
3732
3849
  const logger$2 = createLogger({ prefix: "dep-installer" });
3733
3850
  const execFileAsync = promisify(execFile);
@@ -3763,9 +3880,11 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
3763
3880
  * between build jobs; the directory is removed after installation.
3764
3881
  *
3765
3882
  * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
3766
- * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
3767
- * and the install runs with `--ignore-scripts` so lifecycle scripts in a
3768
- * committed `package.json` cannot exfiltrate the synthesized token env vars.
3883
+ * `.kici/.npmrc` overlay is synthesized for the install and restored in
3884
+ * `finally`.
3885
+ *
3886
+ * Lifecycle scripts are disabled for every package manager unless the operator
3887
+ * set `opts.allowInstallScripts`.
3769
3888
  *
3770
3889
  * @param kiciDir - Path to the `.kici/` directory containing package.json.
3771
3890
  * @param opts - Optional registry / installEnv / repoRoot configuration.
@@ -3787,13 +3906,15 @@ async function installDeps(kiciDir, opts = {}) {
3787
3906
  yarnFlavor
3788
3907
  });
3789
3908
  const startTime = Date.now();
3790
- const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
3909
+ const ignoreScripts = opts.allowInstallScripts !== true;
3910
+ const baseEnv = opts.baseEnv ?? process.env;
3791
3911
  const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
3792
3912
  const registryConfig = isBerry ? await applyYarnrcBerryConfig({
3793
3913
  kiciDir,
3794
3914
  npmRegistries: opts.npmRegistries,
3795
3915
  installEnvSecrets: opts.installEnvSecrets,
3796
- jobIdShort: opts.jobIdShort ?? "00000000"
3916
+ jobIdShort: opts.jobIdShort ?? "00000000",
3917
+ ignoreScripts
3797
3918
  }) : await applyNpmRegistryConfig({
3798
3919
  kiciDir,
3799
3920
  npmRegistries: opts.npmRegistries,
@@ -3803,22 +3924,26 @@ async function installDeps(kiciDir, opts = {}) {
3803
3924
  try {
3804
3925
  if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
3805
3926
  kiciDir,
3806
- hasPrivateRegistry,
3807
- registryConfig
3927
+ ignoreScripts,
3928
+ registryConfig,
3929
+ baseEnv
3808
3930
  });
3809
3931
  else if (isBerry) await runYarnBerryInstall({
3810
3932
  kiciDir,
3811
- registryConfig
3933
+ registryConfig,
3934
+ baseEnv
3812
3935
  });
3813
3936
  else if (packageManager === PackageManager.Yarn) await runYarnInstall({
3814
3937
  kiciDir,
3815
- hasPrivateRegistry,
3816
- registryConfig
3938
+ ignoreScripts,
3939
+ registryConfig,
3940
+ baseEnv
3817
3941
  });
3818
3942
  else await runNpmInstall({
3819
3943
  kiciDir,
3820
- hasPrivateRegistry,
3821
- registryConfig
3944
+ ignoreScripts,
3945
+ registryConfig,
3946
+ baseEnv
3822
3947
  });
3823
3948
  } catch (e) {
3824
3949
  const tokens = registryConfig.tokensForRedaction;
@@ -3828,8 +3953,8 @@ async function installDeps(kiciDir, opts = {}) {
3828
3953
  } finally {
3829
3954
  await registryConfig.cleanup();
3830
3955
  }
3831
- if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
3832
- if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
3956
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot, baseEnv);
3957
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv);
3833
3958
  const durationMs = Date.now() - startTime;
3834
3959
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
3835
3960
  logger$2.info("Deps installed inline", {
@@ -3837,20 +3962,26 @@ async function installDeps(kiciDir, opts = {}) {
3837
3962
  durationMs
3838
3963
  });
3839
3964
  }
3840
- /** Build the Node binary directory onto PATH so spawned tools find `node`. */
3841
- function envWithNodeOnPath(extraEnv, nodeDir) {
3842
- const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
3965
+ /**
3966
+ * Build the Node binary directory onto PATH so spawned tools find `node`.
3967
+ *
3968
+ * `baseEnv` is the caller's declared environment for the subprocess. It
3969
+ * defaults to `process.env` because inside the runner child that IS the
3970
+ * sanitized job environment; an agent-process caller passes a sanitized base.
3971
+ */
3972
+ function envWithNodeOnPath(extraEnv, nodeDir, baseEnv = process.env) {
3973
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = baseEnv;
3843
3974
  return {
3844
3975
  ...restEnv,
3845
3976
  ...extraEnv,
3846
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
3977
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${restEnv.PATH ?? ""}`
3847
3978
  };
3848
3979
  }
3849
3980
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
3850
3981
  async function runNpmInstall(args) {
3851
3982
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
3852
3983
  const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
3853
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3984
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
3854
3985
  const buildArgs = (...prefix) => {
3855
3986
  const a = [
3856
3987
  ...prefix,
@@ -3860,7 +3991,7 @@ async function runNpmInstall(args) {
3860
3991
  "--no-audit",
3861
3992
  "--no-fund"
3862
3993
  ];
3863
- if (args.hasPrivateRegistry) a.push("--ignore-scripts");
3994
+ if (args.ignoreScripts) a.push("--ignore-scripts");
3864
3995
  return a;
3865
3996
  };
3866
3997
  try {
@@ -3889,7 +4020,7 @@ async function runPnpmInstall(args) {
3889
4020
  await assertPnpmAvailable();
3890
4021
  const { nodeDir } = resolveNpm();
3891
4022
  const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
3892
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
4023
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
3893
4024
  const argv = [
3894
4025
  "install",
3895
4026
  `--config.store-dir=${storeDir}`,
@@ -3898,7 +4029,7 @@ async function runPnpmInstall(args) {
3898
4029
  "--config.side-effects-cache=false",
3899
4030
  PNPM_IGNORE_BUILD_GATE_ARG
3900
4031
  ];
3901
- if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
4032
+ if (args.ignoreScripts) argv.push("--ignore-scripts");
3902
4033
  try {
3903
4034
  process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
3904
4035
  await execFileAsync("pnpm", argv, {
@@ -3912,7 +4043,7 @@ async function runPnpmInstall(args) {
3912
4043
  }
3913
4044
  }
3914
4045
  /** Pure: argv for `yarn install` with an isolated cache folder. */
3915
- function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
4046
+ function buildYarnInstallArgs(cacheDir, ignoreScripts) {
3916
4047
  const a = [
3917
4048
  "install",
3918
4049
  "--cache-folder",
@@ -3920,7 +4051,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
3920
4051
  "--non-interactive",
3921
4052
  "--no-progress"
3922
4053
  ];
3923
- if (hasPrivateRegistry) a.push("--ignore-scripts");
4054
+ if (ignoreScripts) a.push("--ignore-scripts");
3924
4055
  return a;
3925
4056
  }
3926
4057
  /**
@@ -3935,8 +4066,8 @@ async function runYarnInstall(args) {
3935
4066
  await assertYarnAvailable();
3936
4067
  const { nodeDir } = resolveNpm();
3937
4068
  const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
3938
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
3939
- const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
4069
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
4070
+ const argv = buildYarnInstallArgs(cacheDir, args.ignoreScripts);
3940
4071
  try {
3941
4072
  process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
3942
4073
  await execFileAsync("yarn", argv, {
@@ -3966,7 +4097,7 @@ async function runYarnBerryInstall(args) {
3966
4097
  await assertYarnAvailable();
3967
4098
  const { nodeDir } = resolveNpm();
3968
4099
  const env = {
3969
- ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
4100
+ ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv),
3970
4101
  COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
3971
4102
  };
3972
4103
  const argv = buildYarnBerryInstallArgs();
@@ -3997,11 +4128,11 @@ async function assertYarnAvailable() {
3997
4128
  * Deep cross-sibling build chains may build out of strict topological order —
3998
4129
  * real `.kici` closures are shallow.
3999
4130
  */
4000
- async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
4131
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv) {
4001
4132
  const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
4002
4133
  if (siblings.length === 0) return;
4003
4134
  const { nodeDir } = resolveNpm();
4004
- const env = envWithNodeOnPath({}, nodeDir);
4135
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
4005
4136
  for (const rel of [...siblings].reverse()) {
4006
4137
  const sibDir = join(repoRoot, rel);
4007
4138
  if (!await siblingHasBuildScript(sibDir)) continue;
@@ -4045,9 +4176,9 @@ async function siblingHasBuildScript(sibDir) {
4045
4176
  * the subprocess stderr/stdout is folded into the thrown error so the job's
4046
4177
  * failure message names the real cause instead of a bare "Command failed".
4047
4178
  */
4048
- async function buildWorkspaceClosure(repoRoot) {
4179
+ async function buildWorkspaceClosure(repoRoot, baseEnv) {
4049
4180
  const { nodeDir } = resolveNpm();
4050
- const env = envWithNodeOnPath({}, nodeDir);
4181
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
4051
4182
  const argv = [
4052
4183
  "--filter",
4053
4184
  "{.kici}^...",
@@ -4141,12 +4272,17 @@ function buildGeneratorContext(input) {
4141
4272
  * ESM modules by resolved URL, so importing that path yields the workflow's live
4142
4273
  * singleton, not a fresh copy.
4143
4274
  *
4144
- * Falls back to the agent's bundled setters when resolution fails (mirrors
4275
+ * Both specifiers resolve to the same module-global maps `internal.ts` and the
4276
+ * root barrel re-export the same `outputs.js` bindings — so the fallback below
4277
+ * changes which entry is imported, never which singleton is mutated.
4278
+ *
4279
+ * Falls back to the agent's bundled setters when neither resolves (mirrors
4145
4280
  * `resolveSdkSetters` in the compiler's test runner).
4146
4281
  */
4147
4282
  async function resolveWorkflowSdkSetters(workflowFilePath) {
4148
- try {
4149
- const sdkEntry = createRequire(workflowFilePath).resolve("@kici-dev/sdk");
4283
+ const req = createRequire(workflowFilePath);
4284
+ for (const specifier of ["@kici-dev/sdk/internal", "@kici-dev/sdk"]) try {
4285
+ const sdkEntry = req.resolve(specifier);
4150
4286
  const sdk = await import(pathToFileURL(sdkEntry).href);
4151
4287
  if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
4152
4288
  setStepOutputsMap: sdk.setStepOutputsMap,
@@ -4160,8 +4296,8 @@ async function resolveWorkflowSdkSetters(workflowFilePath) {
4160
4296
  setJobOutputsMap
4161
4297
  };
4162
4298
  }
4163
- const AGENT_SDK_VERSION = "0.6.1";
4164
- const AGENT_SDK_BUNDLE_HASH = "22faf0da45de7c243ce87f80fd33ee51b1df52809fde457b16bb821678f65eb3";
4299
+ const AGENT_SDK_VERSION = "0.7.0";
4300
+ const AGENT_SDK_BUNDLE_HASH = "065963c7765dc8d87e04d45f57d7e15be1613da705e4ff3ec3742fd1408b7bf5";
4165
4301
  /**
4166
4302
  * Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
4167
4303
  * for subsequent dynamic `import()` calls. Idempotent at our level via the
@@ -4201,10 +4337,49 @@ function ensureLoaderHookRegistered() {
4201
4337
  * with lockfiles compiled on Linux (LF).
4202
4338
  */
4203
4339
  function computeContentHash(rawSource, assetDigest) {
4204
- let input = `5:${normalizeLineEndings(rawSource)}`;
4340
+ let input = `${COMPILE_SCHEMA_VERSION}:${normalizeLineEndings(rawSource)}`;
4205
4341
  if (assetDigest !== void 0 && assetDigest.length > 0) input += `\0${normalizeLineEndings(assetDigest)}`;
4206
4342
  return sha256(input);
4207
4343
  }
4344
+ /**
4345
+ * The compile schema version the lock file records for `sourceFile`, or null
4346
+ * when the tree carries no readable lock (a `file://` in-place run, a workflow
4347
+ * outside the `.kici/` convention, a hand-built fixture).
4348
+ *
4349
+ * The source tarball carries `.kici/kici.lock.json` — the digest excludes it,
4350
+ * but `source-packer.ts` packs it — so the agent can read the producing
4351
+ * compiler's schema version from the tree it already has, with no wire field
4352
+ * to plumb and no protocol change.
4353
+ *
4354
+ * A malformed or unreadable lock returns null rather than throwing: this is a
4355
+ * diagnostic gate in front of the real hash check, so it must never convert a
4356
+ * bad lock into a worse error than the hash comparison already gives.
4357
+ */
4358
+ async function readLockCompileSchemaVersion(kiciDir, sourceFile) {
4359
+ let parsed;
4360
+ try {
4361
+ parsed = JSON.parse(await fsPromises.readFile(path.join(kiciDir, "kici.lock.json"), "utf-8"));
4362
+ } catch {
4363
+ return null;
4364
+ }
4365
+ const workflows = parsed?.workflows;
4366
+ if (!Array.isArray(workflows)) return null;
4367
+ const normalize = (p) => p.replaceAll("\\", "/").replace(/^\.\//, "");
4368
+ const target = normalize(sourceFile);
4369
+ const versionOf = (w) => {
4370
+ const v = w?.compileSchemaVersion;
4371
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
4372
+ };
4373
+ for (const w of workflows) {
4374
+ const file = w?.source?.file;
4375
+ if (typeof file === "string" && normalize(file) === target) return versionOf(w);
4376
+ }
4377
+ for (const w of workflows) {
4378
+ const v = versionOf(w);
4379
+ if (v !== null) return v;
4380
+ }
4381
+ return null;
4382
+ }
4208
4383
  async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
4209
4384
  const parts = [];
4210
4385
  for (const rel of resolvedPaths) {
@@ -4226,20 +4401,30 @@ async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
4226
4401
  * `node_modules/` the same way any `tsx`-style runner would — so host-repo
4227
4402
  * helpers and `@kici-dev/sdk` Just Work.
4228
4403
  *
4229
- * When `expectedContentHash` is provided, verifies the raw source matches
4230
- * the hash in the lock file. Drift between source and lock file produces a
4231
- * descriptive error that surfaces the baked agent SDK fingerprint (useful
4232
- * when debugging "is the agent running a stale build?").
4404
+ * When `expectedContentHash` is provided, verifies the extracted `.kici/` tree
4405
+ * matches the hash in the lock file. It re-hashes the whole tree, not the entry
4406
+ * file alone, so an edit to an imported helper is caught — that was the gap
4407
+ * that let a warm cache restore a stale tarball and run the OLD helper green.
4408
+ * Drift produces a descriptive error that surfaces the baked agent SDK
4409
+ * fingerprint (useful when debugging "is the agent running a stale build?").
4233
4410
  */
4234
4411
  async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, resolvedHashFiles) {
4235
4412
  ensureLoaderHookRegistered();
4236
4413
  const filePath = path.join(workDir, sourceFile);
4237
4414
  if (expectedContentHash) {
4238
- const rawSource = await fsPromises.readFile(filePath, "utf-8");
4415
+ const kiciDir = findKiciDir(filePath);
4416
+ if (kiciDir) {
4417
+ const lockVersion = await readLockCompileSchemaVersion(kiciDir, sourceFile);
4418
+ if (lockVersion !== null && lockVersion !== COMPILE_SCHEMA_VERSION) throw new Error(`kici.lock.json was compiled by an incompatible @kici-dev/compiler: the lock declares compile schema ${lockVersion}, this agent implements ${COMPILE_SCHEMA_VERSION}. The schema version is mixed into every contentHash, so recompiling cannot reconcile them. Align the versions: upgrade the agent to one implementing schema ${lockVersion}, or pin @kici-dev/compiler to a release implementing schema ${COMPILE_SCHEMA_VERSION} and recompile (agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}).`);
4419
+ }
4420
+ const rawSource = (kiciDir ? await hashKiciSourceTree(kiciDir) : "") || await fsPromises.readFile(filePath, "utf-8");
4239
4421
  let assetDigest;
4240
4422
  if (resolvedHashFiles?.length) assetDigest = await buildAssetDigestFromResolvedPaths(workDir, resolvedHashFiles);
4241
4423
  const actualHash = computeContentHash(rawSource, assetDigest);
4242
- if (actualHash !== expectedContentHash) throw new Error(`Lock file is out of date: workflow source changed without regenerating kici.lock.json (expected contentHash ${expectedContentHash}, got ${actualHash}, agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}). Run 'kici compile' and commit the updated lock file.`);
4424
+ if (actualHash !== expectedContentHash) {
4425
+ const symlinkNote = kiciDir ? hashedSymlinkDriftNote(await collectSourceSymlinks(kiciDir)) : "";
4426
+ throw new Error(`Lock file is out of date: workflow source changed without regenerating kici.lock.json (expected contentHash ${expectedContentHash}, got ${actualHash}, agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}). Run 'kici compile' and commit the updated lock file.${symlinkNote}`);
4427
+ }
4243
4428
  }
4244
4429
  return {
4245
4430
  module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
@@ -4402,22 +4587,30 @@ function applyGlobalWorkflowEnv(repos) {
4402
4587
  * `.kici/` source tarball restoration for execution agents.
4403
4588
  *
4404
4589
  * Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
4405
- * and extracts it into `workDir/` so the workflow entry point becomes
4590
+ * and installs it at `workDir/.kici` so the workflow entry point becomes
4406
4591
  * importable. Mirrors the shape of `dep-restore.ts` but without the streaming
4407
4592
  * optimization — source tarballs are tiny (kilobytes, not the hundreds of
4408
4593
  * megabytes a `node_modules/` tarball carries).
4409
4594
  *
4410
- * Note on integrity: `dispatch.sourceTarHash` is the workflow `contentHash`
4411
- * (computed over the raw source per `workflow-loader.ts::computeContentHash`),
4412
- * not the SHA-256 of the tarball bytes. The shared S3 cache key is derived
4413
- * from that same contentHash, so a signed GET URL from the orchestrator
4414
- * already establishes provenance for restored tarballs. Every
4415
- * `loadWorkflowSource` call site build, init, and dynamic eval — passes
4416
- * the dispatched `contentHash` (and `resolvedHashFiles` when present) so
4417
- * the lock-vs-source drift gate fires at each author-TS load site, not
4418
- * only the build phase. That closes the corner cases where init or eval
4419
- * runs without a preceding build (cache infrastructure unavailable, or a
4420
- * build job that failed but left dynamic dispatch in flight).
4595
+ * Two properties this path is responsible for, both of which it previously
4596
+ * lacked:
4597
+ *
4598
+ * **Verification.** `dispatch.sourceTarDigest` is the SHA-256 of the tarball's
4599
+ * own bytes, so the download is checked before anything is extracted — the same
4600
+ * contract `restoreDeps` has always had via `depsHash`. The older
4601
+ * `dispatch.sourceTarHash` field carries the workflow `contentHash` instead, so
4602
+ * it never could serve this purpose; it stays on the wire for older peers and
4603
+ * is deliberately not used as a verification input here. When no digest is
4604
+ * dispatched (an older orchestrator, or a source that did not come from the
4605
+ * content-addressed cache) the restore proceeds unverified rather than failing,
4606
+ * so a mixed-version rollout still runs.
4607
+ *
4608
+ * **Replacement, not overlay.** Extraction lands in a scratch directory and the
4609
+ * result REPLACES `workDir/.kici` wholesale, save for `node_modules/` — the one
4610
+ * directory the tarball deliberately omits, which the deps restore has already
4611
+ * written by the time this runs. Extracting over the existing tree left any file
4612
+ * the tarball no longer carries in place, so a helper the author deleted
4613
+ * survived every warm-cache run and kept being imported.
4421
4614
  */
4422
4615
  init_dep_restore();
4423
4616
  const logger$1 = createLogger({ prefix: "source-restore" });
@@ -4431,7 +4624,14 @@ async function extractSourceTarball(data, targetDir) {
4431
4624
  })).on("finish", resolve).on("error", reject);
4432
4625
  });
4433
4626
  }
4434
- async function restoreSource(workDir, sourceTarUrl) {
4627
+ /**
4628
+ * Download, verify, and install the `.kici/` source tree.
4629
+ *
4630
+ * @param workDir - Root of the cloned repository; `.kici` is replaced under it
4631
+ * @param sourceTarUrl - `http://`, `https://`, or `file://` URL to the tarball
4632
+ * @param sourceTarDigest - Expected SHA-256 of the tarball bytes, when known
4633
+ */
4634
+ async function restoreSource(workDir, sourceTarUrl, sourceTarDigest) {
4435
4635
  sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
4436
4636
  logger$1.info("Restoring .kici/ source from tarball", { sourceTarUrl });
4437
4637
  const startTime = Date.now();
@@ -4441,11 +4641,34 @@ async function restoreSource(workDir, sourceTarUrl) {
4441
4641
  data = await fsPromises.readFile(localPath);
4442
4642
  } else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
4443
4643
  else throw new Error(`Unsupported source tarball URL scheme: ${sourceTarUrl}`);
4444
- await extractSourceTarball(data, workDir);
4644
+ if (sourceTarDigest) {
4645
+ const actual = createHash("sha256").update(data).digest("hex");
4646
+ if (actual !== sourceTarDigest) throw new Error(`Source tarball hash mismatch: expected ${sourceTarDigest}, got ${actual}. The restored source does not match what the orchestrator dispatched.`);
4647
+ }
4648
+ const kiciDir = path.join(workDir, ".kici");
4649
+ const scratch = path.join(workDir, `.kici.restore-${process.pid}-${Date.now()}`);
4650
+ try {
4651
+ await extractSourceTarball(data, scratch);
4652
+ const extracted = path.join(scratch, ".kici");
4653
+ const src = (await fsPromises.stat(extracted).catch(() => null))?.isDirectory() ? extracted : scratch;
4654
+ const installedDeps = path.join(kiciDir, "node_modules");
4655
+ if (await fsPromises.stat(installedDeps).catch(() => null)) await rename(installedDeps, path.join(src, "node_modules"));
4656
+ await rm(kiciDir, {
4657
+ recursive: true,
4658
+ force: true
4659
+ });
4660
+ await rename(src, kiciDir);
4661
+ } finally {
4662
+ await rm(scratch, {
4663
+ recursive: true,
4664
+ force: true
4665
+ }).catch(() => {});
4666
+ }
4445
4667
  const durationMs = Date.now() - startTime;
4446
4668
  logger$1.info(".kici/ source restored", {
4447
4669
  sizeKB: (data.length / 1024).toFixed(2),
4448
- durationMs
4670
+ durationMs,
4671
+ verified: sourceTarDigest !== void 0
4449
4672
  });
4450
4673
  }
4451
4674
  //#endregion
@@ -4594,7 +4817,7 @@ async function applyOverlay(config) {
4594
4817
  */
4595
4818
  init_download();
4596
4819
  init_dep_restore();
4597
- const AGENT_VERSION = "0.6.1";
4820
+ const AGENT_VERSION = "0.7.0";
4598
4821
  process.on("uncaughtException", (err) => {
4599
4822
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
4600
4823
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -4666,7 +4889,12 @@ function installOutputCapture() {
4666
4889
  line
4667
4890
  });
4668
4891
  }
4669
- if (isForkMode) return origStdoutWrite(chunk, encodingOrCb, cb);
4892
+ if (isForkMode) {
4893
+ if (!captureIsActive() || process.env.KICI_RUNNER_DEBUG_STDIO === "true") return origStdoutWrite(chunk, encodingOrCb, cb);
4894
+ const forkCallback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
4895
+ if (forkCallback) forkCallback();
4896
+ return true;
4897
+ }
4670
4898
  const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
4671
4899
  if (callback) callback();
4672
4900
  return true;
@@ -5256,6 +5484,46 @@ function extractRepoIdentifier(repoUrl) {
5256
5484
  * mint failure the statement is frozen + reported for later minting (deferred);
5257
5485
  * the step still completes.
5258
5486
  */
5487
+ /**
5488
+ * The context a deferred attestation's frozen statement is built from.
5489
+ *
5490
+ * Prefers the orchestrator's `provenanceContext` — server truth, derived from
5491
+ * the same run row the mint reads, so the frozen statement is field-for-field
5492
+ * what a live mint would have produced and the orchestrator can cross-check it.
5493
+ *
5494
+ * Falls back to the agent's local view when an older orchestrator sent none.
5495
+ * That fallback disagrees with the claims by construction: `request.ref` is the
5496
+ * job's CHECKOUT ref, which for a pull request is the HEAD branch where the
5497
+ * claim is the BASE branch, and `request.workflowRef` is a global workflow's
5498
+ * CLONE ref where the claim is `<name>@<sha>`. So a statement built from it
5499
+ * fails the capture cross-check and the defer is dropped — a green job with no
5500
+ * attestation, rather than an unchecked statement the orchestrator signs.
5501
+ */
5502
+ function buildLocalContext(request) {
5503
+ const ctx = request.provenanceContext;
5504
+ if (ctx) return {
5505
+ repository: ctx.repository ?? "",
5506
+ ref: ctx.ref ?? "",
5507
+ sha: ctx.sha,
5508
+ workflowRef: ctx.workflowRef ?? "",
5509
+ runId: ctx.runId,
5510
+ jobId: ctx.jobId,
5511
+ orgId: ctx.orgId,
5512
+ sourceOrigin: ctx.sourceOrigin,
5513
+ ...ctx.provider ? { provider: ctx.provider } : {},
5514
+ issuer: ctx.issuer,
5515
+ orchestratorId: ctx.orchestratorId
5516
+ };
5517
+ return {
5518
+ repository: extractRepoIdentifier(request.repoUrl),
5519
+ ref: request.ref,
5520
+ sha: request.sha || null,
5521
+ workflowRef: request.workflowRef ?? request.workflowName,
5522
+ runId: request.runId,
5523
+ jobId: request.jobId,
5524
+ issuer: ""
5525
+ };
5526
+ }
5259
5527
  function buildAttestProvenanceFn(request, workDir, getIdToken) {
5260
5528
  return async (opts) => {
5261
5529
  const subject = provenanceSubjectIsPath(opts.subject) ? {
@@ -5271,15 +5539,7 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
5271
5539
  "kici-agent": AGENT_VERSION,
5272
5540
  "kici-orchestrator": "unknown"
5273
5541
  },
5274
- localContext: {
5275
- repository: extractRepoIdentifier(request.repoUrl),
5276
- ref: request.ref,
5277
- sha: request.sha || null,
5278
- workflowRef: request.workflowRef ?? request.workflowName,
5279
- runId: request.runId,
5280
- jobId: request.jobId,
5281
- issuer: request.provenanceIssuer ?? ""
5282
- },
5542
+ localContext: buildLocalContext(request),
5283
5543
  reportDeferred: async (report) => {
5284
5544
  await relayProvenanceIpc({
5285
5545
  op: "defer",
@@ -5584,20 +5844,6 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
5584
5844
  });
5585
5845
  }
5586
5846
  /**
5587
- * Create a LogMasker initialized with all secret values from the request.
5588
- *
5589
- * Collects values from both flat secrets and all namespaced context secrets,
5590
- * deduplicating before registration.
5591
- */
5592
- function createSecretMasker(request) {
5593
- const masker = new LogMasker();
5594
- const allSecrets = {};
5595
- if (request.secrets) Object.assign(allSecrets, request.secrets);
5596
- if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
5597
- masker.registerSecrets(allSecrets);
5598
- return masker;
5599
- }
5600
- /**
5601
5847
  * Build a fresh zx `$` shell bound to the sandbox working directory and the
5602
5848
  * sanitized environment (process.env was set by the parent via env-sanitizer
5603
5849
  * before spawning this process). This is the single shell-construction code
@@ -5628,12 +5874,13 @@ function createSecretMasker(request) {
5628
5874
  * step$.log would only set it on the function object and NOT propagate to the
5629
5875
  * AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
5630
5876
  */
5631
- function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
5877
+ function buildSandboxShell(cwd, stepIndex, maskedSendFn, signal) {
5632
5878
  return $({
5633
5879
  cwd,
5634
5880
  env: process.env,
5635
5881
  verbose: true,
5636
5882
  quiet: false,
5883
+ ...signal ? { signal } : {},
5637
5884
  log: makeStreamingZxLog((line, stream) => maskedSendFn({
5638
5885
  type: "log.line",
5639
5886
  stepIndex,
@@ -5718,7 +5965,7 @@ function initialJobStatus(loopStatus) {
5718
5965
  * inside this process with full shell access.
5719
5966
  */
5720
5967
  function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal, jobTempScope) {
5721
- const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
5968
+ const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn, signal);
5722
5969
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
5723
5970
  const rawPayload = rawPayloadFromEvent(request.event);
5724
5971
  const kici = buildKiciApi(async (method, params) => {
@@ -5973,7 +6220,8 @@ async function installDependenciesIfNeeded(workflowDir, request) {
5973
6220
  await installDeps(kiciDir, {
5974
6221
  npmRegistries: request.npmRegistries,
5975
6222
  installEnvSecrets: request.installEnvSecrets,
5976
- jobIdShort: request.jobIdShort
6223
+ jobIdShort: request.jobIdShort,
6224
+ ...request.allowInstallScripts ? { allowInstallScripts: true } : {}
5977
6225
  });
5978
6226
  trace("fallback install complete");
5979
6227
  }
@@ -5998,7 +6246,8 @@ async function installDependenciesIfNeeded(workflowDir, request) {
5998
6246
  await installDeps(kiciDir, {
5999
6247
  npmRegistries: request.npmRegistries,
6000
6248
  installEnvSecrets: request.installEnvSecrets,
6001
- jobIdShort: request.jobIdShort
6249
+ jobIdShort: request.jobIdShort,
6250
+ ...request.allowInstallScripts ? { allowInstallScripts: true } : {}
6002
6251
  });
6003
6252
  trace("installDeps() returned successfully");
6004
6253
  } catch (depErr) {
@@ -6028,7 +6277,7 @@ async function restoreSourceTarballIfRequested(workflowRoot, request) {
6028
6277
  stepIndex: -1,
6029
6278
  line: "[workflow-runner] Restoring .kici/ source from cached tarball"
6030
6279
  });
6031
- await restoreSource(workflowRoot, request.sourceTarUrl);
6280
+ await restoreSource(workflowRoot, request.sourceTarUrl, request.sourceTarDigest);
6032
6281
  trace("source tarball restored");
6033
6282
  }
6034
6283
  /**
@@ -6529,7 +6778,7 @@ function buildJobRuleCompletion(ruleResult, normalizedSteps) {
6529
6778
  * success-skip). Returns false when the caller should continue to step
6530
6779
  * execution.
6531
6780
  */
6532
- async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
6781
+ async function maybeSkipJobOnRules(job, request, normalizedSteps, send, repos) {
6533
6782
  if (!job?.rules || job.rules.length === 0) return false;
6534
6783
  const ev = request.event ?? {};
6535
6784
  const ruleCtx = createRuleContext({
@@ -6548,7 +6797,7 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
6548
6797
  if (!completion) return false;
6549
6798
  flushOutputCapture();
6550
6799
  capturePrepareActive = false;
6551
- sendMessage(completion);
6800
+ send(completion);
6552
6801
  process.exit(0);
6553
6802
  }
6554
6803
  /**
@@ -6774,11 +7023,7 @@ async function main() {
6774
7023
  const sourceDir = isGlobal ? join(workDir, "source") : workDir;
6775
7024
  const masker = createSecretMasker(request);
6776
7025
  const maskedSend = (msg) => {
6777
- if (msg.type === "log.line" && masker.hasSecrets()) sendMessage({
6778
- ...msg,
6779
- line: masker.mask(msg.line)
6780
- });
6781
- else sendMessage(msg);
7026
+ sendMessage(maskMessageText(msg, masker));
6782
7027
  };
6783
7028
  const jobDeadline = armJobDeadline(request.jobTimeoutMs, (reason, timeoutMs) => {
6784
7029
  jobTimedOut = true;
@@ -6813,7 +7058,7 @@ async function main() {
6813
7058
  const jobHasRules = (job?.rules?.length ?? 0) > 0;
6814
7059
  const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
6815
7060
  await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
6816
- await maybeSkipJobOnRules(job, request, normalizedSteps, globalRepoInfo);
7061
+ await maybeSkipJobOnRules(job, request, normalizedSteps, maskedSend, globalRepoInfo);
6817
7062
  if (aborted) abortAndExit("aborted after rules");
6818
7063
  }
6819
7064
  const jobHooks = collectJobHooks(job);
@@ -6922,7 +7167,8 @@ async function main() {
6922
7167
  jobTimedOut,
6923
7168
  jobTimeoutMs: request.jobTimeoutMs,
6924
7169
  cancelFailureReason,
6925
- driftDroppedJobs
7170
+ driftDroppedJobs,
7171
+ send: maskedSend
6926
7172
  });
6927
7173
  process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
6928
7174
  }
@@ -6935,7 +7181,7 @@ async function main() {
6935
7181
  function emitJobComplete(args) {
6936
7182
  const aggregatedOutputs = {};
6937
7183
  for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
6938
- sendMessage({
7184
+ args.send({
6939
7185
  type: "job.complete",
6940
7186
  status: args.finalStatus,
6941
7187
  stepResults: args.loopResult.stepResults,