@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
package/dist/index.js CHANGED
@@ -105,6 +105,10 @@ const configSchema = z.object({
105
105
  sandboxHardened: z.string().default("true").transform((s) => s !== "false"),
106
106
  sandboxReadonlyRootfs: z.string().default("false").transform((s) => s === "true"),
107
107
  sandboxUser: z.string().optional(),
108
+ runnerUser: z.string().optional(),
109
+ runnerDebugStdio: z.string().default("false").transform((s) => s === "true"),
110
+ sandboxNetworkIsolation: z.string().default("true").transform((s) => s !== "false"),
111
+ allowInstallScripts: z.string().default("false").transform((s) => s === "true"),
108
112
  sandboxPidsLimit: z.coerce.number().int().positive().default(512),
109
113
  sandboxMemoryBytes: z.coerce.number().int().positive().default(2147483648),
110
114
  sandboxNanoCpus: z.coerce.number().int().positive().default(2e9),
@@ -159,6 +163,10 @@ const envDef = defineEnv({
159
163
  sandboxHardened: "KICI_SANDBOX_HARDENED",
160
164
  sandboxReadonlyRootfs: "KICI_SANDBOX_READONLY_ROOTFS",
161
165
  sandboxUser: "KICI_SANDBOX_USER",
166
+ runnerUser: "KICI_RUNNER_USER",
167
+ runnerDebugStdio: "KICI_RUNNER_DEBUG_STDIO",
168
+ sandboxNetworkIsolation: "KICI_SANDBOX_NETWORK_ISOLATION",
169
+ allowInstallScripts: "KICI_ALLOW_INSTALL_SCRIPTS",
162
170
  sandboxPidsLimit: "KICI_SANDBOX_PIDS_LIMIT",
163
171
  sandboxMemoryBytes: "KICI_SANDBOX_MEMORY_BYTES",
164
172
  sandboxNanoCpus: "KICI_SANDBOX_NANO_CPUS",
@@ -212,6 +220,10 @@ const envDef = defineEnv({
212
220
  * - KICI_SCALER_CLAIM_CODE (optional single-use claim code the agent exchanges for its own ephemeral credentials before registering; ignored when KICI_AGENT_TOKEN is set)
213
221
  * - KICI_SCALER_IDLE_TIMEOUT (ms, default 5000) — how long a scaler-managed agent waits before shutdown after going idle
214
222
  * - KICI_SCALER_PENDING_DISPATCH_TIMEOUT (ms, default 60000) — extended idle window when register.ack signals a queued bound job
223
+ * - KICI_RUNNER_USER (optional, uid | uid:gid | name) — run the bare-metal runner child as a dedicated user instead of the agent's own
224
+ * - KICI_ALLOW_INSTALL_SCRIPTS (default: false) — re-enable package lifecycle scripts during the `.kici/` dependency install
225
+ * - KICI_SANDBOX_NETWORK_ISOLATION (default: true) — RFC1918 + cloud-metadata egress filtering for nested job containers
226
+ * - KICI_RUNNER_DEBUG_STDIO (default: false) — echo the runner child's raw stdio onto the agent's stderr
215
227
  * - KICI_EXECUTION_MODE (optional, options: container | bare-metal | firecracker) — override the runner's mode-pick logic
216
228
  * - KICI_CONCURRENCY_WAIT_TIMEOUT_MS (default: 3_600_000) — workflow-runner timeout when long-polling for a slot-release follow-up `concurrency.ack`
217
229
  * - KICI_AGENT_BETWEEN_JOBS_RESET_COMMAND (optional) — host-reset command run between jobs on a reused agent (fail-open)
@@ -220,10 +232,23 @@ const envDef = defineEnv({
220
232
  * - KICI_AGENT_ORPHAN_CLEANUP (default: true) — reap a finished job's leaked process tree (bare-metal); false = only signal the runner child
221
233
  * - KICI_AGENT_DRAIN_ON_RESET_FAILURE (default: false) — drain the agent after repeated consecutive reset failures
222
234
  */
235
+ /**
236
+ * Env vars the boot-time unknown-KICI_* validator tolerates **only** under
237
+ * `NODE_ENV=test`. `KICI_SKIP_DB_TESTS` is the vitest Postgres harness's
238
+ * opt-out (`scripts/db-test-postgres.ts`), so a suite run with it set must not
239
+ * fail the agent's own config tests. Mirrors the orchestrator's list, which
240
+ * already carries it. Under any other NODE_ENV the allowance is empty, so a
241
+ * production boot with a stray one still fails the validator.
242
+ */
243
+ const TEST_ONLY_ENV_VARS = ["KICI_SKIP_DB_TESTS"];
223
244
  function loadConfig() {
224
245
  const data = envDef.parse();
225
246
  if (!data.scalerManaged && !data.agentToken) validateNoReservedLabels(data.labels, "KICI_LABELS");
226
- validateUnknownKiciVars([...envDef.listKnownEnvVars(), ...LOGGER_ENV_VARS]);
247
+ validateUnknownKiciVars([
248
+ ...envDef.listKnownEnvVars(),
249
+ ...LOGGER_ENV_VARS,
250
+ ...process.env.NODE_ENV === "test" ? TEST_ONLY_ENV_VARS : []
251
+ ]);
227
252
  return {
228
253
  ...data,
229
254
  agentId: data.agentId ?? `${hostname()}-${randomUUID().slice(0, 8)}`
@@ -390,10 +415,12 @@ function redactNpmOutput(input, tokens) {
390
415
  *
391
416
  * `nodeLinker: node-modules` makes berry lay down a real `node_modules` tree
392
417
  * (no PnP `.pnp.cjs`), so the agent's packer / restore / sibling-walk /
393
- * workflow-loader work unchanged. `enableScripts: false` (when a private
394
- * registry is configured) keeps dependency lifecycle scripts from seeing the
395
- * synthesized token env vars — the same security model as npm/pnpm/classic
396
- * `--ignore-scripts`.
418
+ * workflow-loader work unchanged. `enableScripts: false` keeps dependency
419
+ * lifecycle scripts from running at all for every install, not only one
420
+ * against a private registry — the same security model as npm/pnpm/classic
421
+ * `--ignore-scripts`. An operator opts back in with
422
+ * `KICI_ALLOW_INSTALL_SCRIPTS=true`, which arrives here as
423
+ * `ignoreScripts: false`.
397
424
  *
398
425
  * Reuses the same `ApplyNpmRegistryConfigArgs` / `ApplyNpmRegistryConfigResult`
399
426
  * shapes as the npm overlay so `dep-installer` can pick either by flavor.
@@ -440,8 +467,8 @@ async function applyYarnrcBerryConfig(args) {
440
467
  };
441
468
  const tokenEnv = {};
442
469
  const tokensForRedaction = [];
470
+ if (args.ignoreScripts !== false) merged.enableScripts = false;
443
471
  if (hasPrivateRegistry) {
444
- merged.enableScripts = false;
445
472
  const npmScopes = { ...doc.npmScopes ?? {} };
446
473
  for (let i = 0; i < registries.length; i++) {
447
474
  const reg = registries[i];
@@ -787,9 +814,11 @@ function isAbsoluteRel(rel) {
787
814
  * Security: the install runs with an isolated per-invocation cache/store
788
815
  * directory to prevent cache poisoning across build jobs — a malicious
789
816
  * package.json in one repo cannot taint the cache used by subsequent builds.
790
- * The same pressure rules out letting lifecycle scripts see synthesized auth
791
- * env vars the install runs with `--ignore-scripts` whenever a private
792
- * registry is configured.
817
+ * The install runs with `--ignore-scripts` for every package manager. A
818
+ * lifecycle script in a committed `package.json` is customer code the agent
819
+ * never agreed to execute: it would run wherever the install runs, which for a
820
+ * step-child install is the process holding the job's secrets. Operators who
821
+ * genuinely need it set `KICI_ALLOW_INSTALL_SCRIPTS=true` on the agent.
793
822
  */
794
823
  const logger$3 = createLogger({ prefix: "dep-installer" });
795
824
  const execFileAsync = promisify(execFile);
@@ -825,9 +854,11 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
825
854
  * between build jobs; the directory is removed after installation.
826
855
  *
827
856
  * If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
828
- * `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
829
- * and the install runs with `--ignore-scripts` so lifecycle scripts in a
830
- * committed `package.json` cannot exfiltrate the synthesized token env vars.
857
+ * `.kici/.npmrc` overlay is synthesized for the install and restored in
858
+ * `finally`.
859
+ *
860
+ * Lifecycle scripts are disabled for every package manager unless the operator
861
+ * set `opts.allowInstallScripts`.
831
862
  *
832
863
  * @param kiciDir - Path to the `.kici/` directory containing package.json.
833
864
  * @param opts - Optional registry / installEnv / repoRoot configuration.
@@ -849,13 +880,15 @@ async function installDeps(kiciDir, opts = {}) {
849
880
  yarnFlavor
850
881
  });
851
882
  const startTime = Date.now();
852
- const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
883
+ const ignoreScripts = opts.allowInstallScripts !== true;
884
+ const baseEnv = opts.baseEnv ?? process.env;
853
885
  const isBerry = packageManager === PackageManager.Yarn && yarnFlavor === YarnFlavor.Berry;
854
886
  const registryConfig = isBerry ? await applyYarnrcBerryConfig({
855
887
  kiciDir,
856
888
  npmRegistries: opts.npmRegistries,
857
889
  installEnvSecrets: opts.installEnvSecrets,
858
- jobIdShort: opts.jobIdShort ?? "00000000"
890
+ jobIdShort: opts.jobIdShort ?? "00000000",
891
+ ignoreScripts
859
892
  }) : await applyNpmRegistryConfig({
860
893
  kiciDir,
861
894
  npmRegistries: opts.npmRegistries,
@@ -865,22 +898,26 @@ async function installDeps(kiciDir, opts = {}) {
865
898
  try {
866
899
  if (packageManager === PackageManager.Pnpm) await runPnpmInstall({
867
900
  kiciDir,
868
- hasPrivateRegistry,
869
- registryConfig
901
+ ignoreScripts,
902
+ registryConfig,
903
+ baseEnv
870
904
  });
871
905
  else if (isBerry) await runYarnBerryInstall({
872
906
  kiciDir,
873
- registryConfig
907
+ registryConfig,
908
+ baseEnv
874
909
  });
875
910
  else if (packageManager === PackageManager.Yarn) await runYarnInstall({
876
911
  kiciDir,
877
- hasPrivateRegistry,
878
- registryConfig
912
+ ignoreScripts,
913
+ registryConfig,
914
+ baseEnv
879
915
  });
880
916
  else await runNpmInstall({
881
917
  kiciDir,
882
- hasPrivateRegistry,
883
- registryConfig
918
+ ignoreScripts,
919
+ registryConfig,
920
+ baseEnv
884
921
  });
885
922
  } catch (e) {
886
923
  const tokens = registryConfig.tokensForRedaction;
@@ -890,8 +927,8 @@ async function installDeps(kiciDir, opts = {}) {
890
927
  } finally {
891
928
  await registryConfig.cleanup();
892
929
  }
893
- if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
894
- if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor);
930
+ if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot, baseEnv);
931
+ if (packageManager === PackageManager.Yarn) await buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv);
895
932
  const durationMs = Date.now() - startTime;
896
933
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
897
934
  logger$3.info("Deps installed inline", {
@@ -899,20 +936,26 @@ async function installDeps(kiciDir, opts = {}) {
899
936
  durationMs
900
937
  });
901
938
  }
902
- /** Build the Node binary directory onto PATH so spawned tools find `node`. */
903
- function envWithNodeOnPath(extraEnv, nodeDir) {
904
- const { NODE_ENV: _NODE_ENV, ...restEnv } = process.env;
939
+ /**
940
+ * Build the Node binary directory onto PATH so spawned tools find `node`.
941
+ *
942
+ * `baseEnv` is the caller's declared environment for the subprocess. It
943
+ * defaults to `process.env` because inside the runner child that IS the
944
+ * sanitized job environment; an agent-process caller passes a sanitized base.
945
+ */
946
+ function envWithNodeOnPath(extraEnv, nodeDir, baseEnv = process.env) {
947
+ const { NODE_ENV: _NODE_ENV, ...restEnv } = baseEnv;
905
948
  return {
906
949
  ...restEnv,
907
950
  ...extraEnv,
908
- PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
951
+ PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${restEnv.PATH ?? ""}`
909
952
  };
910
953
  }
911
954
  /** Run `npm install` in `.kici/` with an isolated cache directory. */
912
955
  async function runNpmInstall(args) {
913
956
  const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
914
957
  const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
915
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
958
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
916
959
  const buildArgs = (...prefix) => {
917
960
  const a = [
918
961
  ...prefix,
@@ -922,7 +965,7 @@ async function runNpmInstall(args) {
922
965
  "--no-audit",
923
966
  "--no-fund"
924
967
  ];
925
- if (args.hasPrivateRegistry) a.push("--ignore-scripts");
968
+ if (args.ignoreScripts) a.push("--ignore-scripts");
926
969
  return a;
927
970
  };
928
971
  try {
@@ -951,7 +994,7 @@ async function runPnpmInstall(args) {
951
994
  await assertPnpmAvailable();
952
995
  const { nodeDir } = resolveNpm();
953
996
  const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
954
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
997
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
955
998
  const argv = [
956
999
  "install",
957
1000
  `--config.store-dir=${storeDir}`,
@@ -960,7 +1003,7 @@ async function runPnpmInstall(args) {
960
1003
  "--config.side-effects-cache=false",
961
1004
  PNPM_IGNORE_BUILD_GATE_ARG
962
1005
  ];
963
- if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
1006
+ if (args.ignoreScripts) argv.push("--ignore-scripts");
964
1007
  try {
965
1008
  process.stderr.write(`[dep-installer:trace] running: pnpm ${argv.join(" ")}\n`);
966
1009
  await execFileAsync("pnpm", argv, {
@@ -974,7 +1017,7 @@ async function runPnpmInstall(args) {
974
1017
  }
975
1018
  }
976
1019
  /** Pure: argv for `yarn install` with an isolated cache folder. */
977
- function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
1020
+ function buildYarnInstallArgs(cacheDir, ignoreScripts) {
978
1021
  const a = [
979
1022
  "install",
980
1023
  "--cache-folder",
@@ -982,7 +1025,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
982
1025
  "--non-interactive",
983
1026
  "--no-progress"
984
1027
  ];
985
- if (hasPrivateRegistry) a.push("--ignore-scripts");
1028
+ if (ignoreScripts) a.push("--ignore-scripts");
986
1029
  return a;
987
1030
  }
988
1031
  /**
@@ -997,8 +1040,8 @@ async function runYarnInstall(args) {
997
1040
  await assertYarnAvailable();
998
1041
  const { nodeDir } = resolveNpm();
999
1042
  const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
1000
- const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
1001
- const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
1043
+ const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv);
1044
+ const argv = buildYarnInstallArgs(cacheDir, args.ignoreScripts);
1002
1045
  try {
1003
1046
  process.stderr.write(`[dep-installer:trace] running: yarn ${argv.join(" ")}\n`);
1004
1047
  await execFileAsync("yarn", argv, {
@@ -1028,7 +1071,7 @@ async function runYarnBerryInstall(args) {
1028
1071
  await assertYarnAvailable();
1029
1072
  const { nodeDir } = resolveNpm();
1030
1073
  const env = {
1031
- ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir),
1074
+ ...envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir, args.baseEnv),
1032
1075
  COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
1033
1076
  };
1034
1077
  const argv = buildYarnBerryInstallArgs();
@@ -1059,11 +1102,11 @@ async function assertYarnAvailable() {
1059
1102
  * Deep cross-sibling build chains may build out of strict topological order —
1060
1103
  * real `.kici` closures are shallow.
1061
1104
  */
1062
- async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor) {
1105
+ async function buildYarnWorkspaceClosure(repoRoot, kiciDir, yarnFlavor, baseEnv) {
1063
1106
  const siblings = await collectInRepoSiblings(repoRoot, kiciDir, resolveYarnNodeModulesRoot(repoRoot, kiciDir));
1064
1107
  if (siblings.length === 0) return;
1065
1108
  const { nodeDir } = resolveNpm();
1066
- const env = envWithNodeOnPath({}, nodeDir);
1109
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
1067
1110
  for (const rel of [...siblings].reverse()) {
1068
1111
  const sibDir = join(repoRoot, rel);
1069
1112
  if (!await siblingHasBuildScript(sibDir)) continue;
@@ -1107,9 +1150,9 @@ async function siblingHasBuildScript(sibDir) {
1107
1150
  * the subprocess stderr/stdout is folded into the thrown error so the job's
1108
1151
  * failure message names the real cause instead of a bare "Command failed".
1109
1152
  */
1110
- async function buildWorkspaceClosure(repoRoot) {
1153
+ async function buildWorkspaceClosure(repoRoot, baseEnv) {
1111
1154
  const { nodeDir } = resolveNpm();
1112
- const env = envWithNodeOnPath({}, nodeDir);
1155
+ const env = envWithNodeOnPath({}, nodeDir, baseEnv);
1113
1156
  const argv = [
1114
1157
  "--filter",
1115
1158
  "{.kici}^...",
@@ -52,27 +52,38 @@ export interface LocalBuildContext {
52
52
  repository: string;
53
53
  ref: string;
54
54
  sha: string | null;
55
+ /**
56
+ * The token's `workflow_ref` claim (`<name>@<sha>`) when the orchestrator
57
+ * supplied one. NOT a global workflow's clone ref — those are never equal,
58
+ * and comparing them is what a cross-check would have failed on.
59
+ */
55
60
  workflowRef: string;
56
61
  runId: string;
57
62
  jobId: string;
58
63
  orgId?: string;
59
64
  sourceOrigin?: SourceOrigin;
65
+ /** Informational source provider, mirroring the live builder's `provider`. */
66
+ provider?: string;
67
+ /** The orchestrator's provenance issuer, for the `builder.id`. */
68
+ issuer: string;
60
69
  /**
61
- * Platform provenance issuer for the `builder.id`. The agent does not always
62
- * know it at build time (the orchestrator may be disconnected — that is why
63
- * the mint deferred), so it is best-effort; an empty string yields a
64
- * `/orchestrator/unknown` builder id. This field is not verification
65
- * load-bearing for a deferred bundle: the later token binds to the frozen
66
- * statement by hash, not by field-for-field cross-check.
70
+ * The orchestrator instance id, for the `builder.id`. Absent when the
71
+ * orchestrator sent no provenance context, which yields the honest
72
+ * `/orchestrator/unknown` the local guess has always produced.
67
73
  */
68
- issuer: string;
74
+ orchestratorId?: string;
69
75
  }
70
76
  /**
71
77
  * Build a frozen SLSA v1.0 provenance statement from agent-local job context,
72
78
  * for a deferred attestation (no minted identity token yet). Marks
73
79
  * `attestationOrigin: 'deferred'` in the internal parameters. The caller
74
80
  * DSSE-signs the returned statement immediately and computes its statement hash
75
- * — the binding the later OIDC mint commits to (truth-contract property 2).
81
+ * — one of the two bindings the later OIDC mint commits to.
82
+ *
83
+ * Emits the same fields `buildProvenanceStatement` emits, so a statement frozen
84
+ * from an orchestrator-supplied context is field-for-field what a live mint
85
+ * would have produced. That is what lets the orchestrator cross-check the
86
+ * statement against its own run row before signing anything that commits to it.
76
87
  */
77
88
  export declare function buildLocalProvenanceStatement(input: {
78
89
  context: LocalBuildContext;