@tea-agent/loop-agent 0.26.2 → 0.26.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.26.4] - 2026-08-03
6
+
7
+ ### 重点更新
8
+
9
+ - 强化 DAG 运行时安全与 writer 工具级写边界
10
+ - 收敛效率与可选 document catalog 兼容
11
+ - 防污染 review-pass 知识写入
12
+
13
+ ### 改进
14
+
15
+ - fresh init 与旧项目 `init update --apply-safe` 刷新的 `AGENTS.md` managed block 现要求持续监控 DAG 至 `FINISHED`、`FAILED`、`partial_failed` 等已结束终态或 Decision Gate 需要 approve;判活须组合 runner heartbeat、session events 与 `dag doctor` liveness/provider meaningful progress,并禁止 exclusive writer 运行期间主会话或其他 writer 并发修改工作区
16
+ - DAG 治理闭包与收敛效率:已配置 `docs/document-catalog.json` 的项目在生成时从 `completeDirectoryIndexes` 注入具体 index companion 到 implement/repair 内存路径,未配置该可选 catalog 的项目保持原有 DAG 生成能力;repair artifact 支持 `blocked-boundary`/`requiredScope` 与 empty-manifest `no-progress` 提前止损;DAG shell 支持显式 `failFast`,supervised hard verify 在同 run、同工作区指纹与命令契约下仅复用成功命令并写可审计 receipt。真实非零退出与 finished 聚合规则不放宽,且不跨 run 缓存验证结果
17
+ - DAG Pi writer 工具级写边界:移除 writer unrestricted `bash`,以 SDK 同名受控 `edit`/`write` 在文件 API 执行前强制 repo containment、symlink/ancestor containment、`allowedPaths`/`writeSet`/`forbiddenPaths`,并硬拒绝 `.harness/tasks/**` 与 `.harness/dag-runs/**`;CLI-only 与 SDK→CLI 对 writer fail-closed(read-only `cli-only` rollback 保留),SDK resource loader 与 CLI 同时关闭 Pi extension 自动发现,阻止 extension 在 session 启动或覆盖工具后绕过沙箱。授权递归 glob(如 `src/**`)可创建其覆盖的中间目录,但同级越界目录仍在 mutation 前拒绝。same-run hard-verify workspace fingerprint 纳入当前 Task Contract 稳定 ignored 治理输入;这些文件漂移后成功命令全部 `reused=false`,runner-owned dag-runs volatile 不进入指纹。post-run write-guard 与 change-manifest 保持第二层防御
18
+ - DAG convergence 成功知识防污染:只有 `review-pi` 与 review verdict recovery 均 FINISHED 且明确 `VERDICT: pass` 才写入 `review-pass` knowledge pattern;review gate 仅完成路由但上游 authority/protocol/review verdict 非 pass 时按 `non-retry-failure` 收口,不再把 `request-revision` 或被跳过的 review 误记为成功
19
+
20
+ ### 修复
21
+
22
+ - 修复未配置 document catalog 的项目在 DAG 生成路径上的兼容问题,避免可选 catalog 成为硬依赖
23
+ - 提升 convergence 与 writer 可靠性
24
+
25
+ ## [0.26.3] - 2026-08-02
26
+
27
+ ### 重点更新
28
+
29
+ - DAG 无害工具副产物自动恢复
30
+ - 提升前端验证命令的精确度与失败分类
31
+ - 隔离测试固件与运行环境标记
32
+
33
+ ### 新增
34
+
35
+ - DAG 无害工具副产物自动恢复:自动清理 Git Bash 误用 `> nul` / `2> nul` 在仓库根目录生成的普通非 symlink untracked `nul` 文件,避免其干扰 Git 状态复查与变更清单生成
36
+
37
+ ### 改进
38
+
39
+ - 精确限定前端验证命令的作用域,提升验证执行的准确性
40
+ - 更精准地分类前端验证失败原因,避免模糊报错
41
+ - 将运行器标记与测试固件进行安全隔离,防止测试环境干扰
42
+
43
+ ### 修复
44
+
45
+ - 修复配置验证目标错误处理符号引用的问题
46
+ - 修复 DAG 执行中无害的根目录 nul 副产物导致流程异常的问题
47
+
5
48
  ## [0.26.2] - 2026-08-02
6
49
 
7
50
  ### 重点更新
@@ -2,13 +2,14 @@ import path from "node:path";
2
2
  import { createHash } from "node:crypto";
3
3
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
4
4
  import { executePiStep, } from "./pi-executor.js";
5
+ import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
5
6
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
- import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
+ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
8
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
8
9
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
10
+ /** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
9
11
  export const DAG_PI_WRITE_TOOLS = [
10
12
  "read",
11
- "bash",
12
13
  "edit",
13
14
  "write",
14
15
  "grep",
@@ -259,6 +260,7 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
259
260
  }
260
261
  const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
261
262
  readGitStatusPorcelain,
263
+ recoverRootNulArtifact,
262
264
  };
263
265
  export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
264
266
  const started = Date.now();
@@ -312,6 +314,29 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
312
314
  });
313
315
  }
314
316
  : undefined;
317
+ let writerToolPolicy;
318
+ if (isWriteTask) {
319
+ try {
320
+ const policyContext = buildPiWriterToolPolicyContext({
321
+ repoRoot: input.cwd,
322
+ allowedPaths: input.task.allowedPaths,
323
+ writeSet: input.task.writeSet,
324
+ forbiddenPaths: input.task.forbiddenPaths,
325
+ writePolicy: input.task.writePolicy,
326
+ });
327
+ const customTools = await createPiWriterCustomTools(policyContext);
328
+ writerToolPolicy = { requireSdk: true, customTools };
329
+ }
330
+ catch (error) {
331
+ return {
332
+ ok: false,
333
+ stdout: "",
334
+ stderr: `pi writer tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
335
+ failureCategory: "tool-policy",
336
+ durationMs: Date.now() - started,
337
+ };
338
+ }
339
+ }
315
340
  const result = await piStepFn({
316
341
  attachedFiles: [],
317
342
  modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
@@ -325,6 +350,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
325
350
  timeoutMs: input.timeoutMs,
326
351
  stallTimeoutMs: input.stallTimeoutMs,
327
352
  abortGraceMs: input.abortGraceMs,
353
+ ...(writerToolPolicy ? { writerToolPolicy } : {}),
328
354
  onActivity: bridgeActivity
329
355
  ? (activity) => {
330
356
  if (activity.kind === "lease" ||
@@ -360,10 +386,105 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
360
386
  let changeManifestAfterStatus;
361
387
  let changeManifestChangedFiles;
362
388
  if (beforeStatus !== undefined) {
389
+ let recoveryEvidence;
390
+ let removalPendingRecheck;
363
391
  try {
364
- const afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
365
- const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
366
- const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
392
+ let afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
393
+ let afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
394
+ let afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
395
+ // Benign tool artifact recovery: a new untracked bounded regular file
396
+ // literally named `nul` at the repository root is removed with
397
+ // the Node file API, then git status is recaptured so the guard,
398
+ // outcome validation and change manifest only ever see the
399
+ // sanitized diff. Every non-matching candidate stays fail-closed.
400
+ if (afterSnapshot.has("nul")) {
401
+ const recoverRootNulArtifactFn = writeGuardDependencies.recoverRootNulArtifact ??
402
+ recoverRootNulArtifact;
403
+ const recovery = await recoverRootNulArtifactFn({
404
+ rootCwd: input.cwd,
405
+ beforeSnapshot: snapshotGitStatusPorcelain(beforeStatus),
406
+ afterSnapshot,
407
+ afterPathFingerprints,
408
+ candidateAuthorized: validateShellWriteGuardFromDiff({
409
+ changedFiles: ["nul"],
410
+ task: input.task,
411
+ concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
412
+ }).ok,
413
+ });
414
+ const beforeStatusSha256 = createHash("sha256")
415
+ .update(beforeStatus)
416
+ .digest("hex");
417
+ const afterStatusSha256 = createHash("sha256")
418
+ .update(afterStatus)
419
+ .digest("hex");
420
+ if (recovery.action === "removed") {
421
+ const evidenceBase = {
422
+ candidatePath: "nul",
423
+ reason: recovery.reason,
424
+ action: "recovered",
425
+ result: "removed",
426
+ observedSizeBytes: recovery.observedSizeBytes,
427
+ removedAt: recovery.removedAt,
428
+ beforeStatusSha256,
429
+ afterStatusSha256,
430
+ };
431
+ removalPendingRecheck = evidenceBase;
432
+ const recheckedStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, {
433
+ phase: "pi-writer-after-recheck",
434
+ });
435
+ const recheckedSnapshot = snapshotGitStatusPorcelain(recheckedStatus);
436
+ afterStatus = recheckedStatus;
437
+ afterSnapshot = recheckedSnapshot;
438
+ afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, recheckedSnapshot);
439
+ recoveryEvidence = {
440
+ ...evidenceBase,
441
+ recheck: {
442
+ statusSha256: createHash("sha256")
443
+ .update(recheckedStatus)
444
+ .digest("hex"),
445
+ nulStillPresent: recheckedSnapshot.has("nul"),
446
+ },
447
+ };
448
+ removalPendingRecheck = undefined;
449
+ }
450
+ else if (recovery.action === "skipped") {
451
+ recoveryEvidence = {
452
+ candidatePath: "nul",
453
+ reason: recovery.reason,
454
+ action: "skipped",
455
+ result: "kept",
456
+ ...(recovery.observedSizeBytes === undefined
457
+ ? {}
458
+ : { observedSizeBytes: recovery.observedSizeBytes }),
459
+ beforeStatusSha256,
460
+ afterStatusSha256,
461
+ recheck: {
462
+ statusSha256: afterStatusSha256,
463
+ nulStillPresent: true,
464
+ },
465
+ };
466
+ }
467
+ else {
468
+ recoveryEvidence = {
469
+ candidatePath: "nul",
470
+ reason: recovery.reason,
471
+ action: "failed",
472
+ result: "removal-failed",
473
+ observedSizeBytes: recovery.observedSizeBytes,
474
+ errorDetail: recovery.errorDetail,
475
+ beforeStatusSha256,
476
+ afterStatusSha256,
477
+ recheck: {
478
+ statusSha256: afterStatusSha256,
479
+ nulStillPresent: true,
480
+ },
481
+ };
482
+ writeGuardOk = false;
483
+ writeGuardViolations = [
484
+ `nul artifact removal failed: ${recovery.errorDetail}`,
485
+ ];
486
+ }
487
+ }
367
488
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
368
489
  changeManifestAfterStatus = afterStatus;
369
490
  changeManifestChangedFiles = changedFiles;
@@ -372,16 +493,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
372
493
  task: input.task,
373
494
  concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
374
495
  });
375
- writeGuardOk = guard.ok;
376
- writeGuardViolations = guard.violations;
496
+ writeGuardOk = writeGuardOk && guard.ok;
497
+ writeGuardViolations = [...writeGuardViolations, ...guard.violations];
377
498
  }
378
499
  catch (error) {
379
500
  await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
501
+ if (removalPendingRecheck) {
502
+ // The artifact was already deleted but the recheck read failed:
503
+ // record the recovery facts with the recheck failure so the
504
+ // evidence trail stays complete while the node remains failed.
505
+ recoveryEvidence = {
506
+ ...removalPendingRecheck,
507
+ recheck: {
508
+ failed: true,
509
+ errorDetail: boundedRecoveryErrorDetail(error),
510
+ },
511
+ };
512
+ }
380
513
  writeGuardOk = false;
381
514
  writeGuardViolations = [
382
515
  `git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
383
516
  ];
384
517
  }
518
+ if (recoveryEvidence) {
519
+ await persistBenignToolArtifactRecovery({
520
+ runDir: meta.runDir,
521
+ nodeId: input.task.id,
522
+ evidence: recoveryEvidence,
523
+ });
524
+ }
385
525
  }
386
526
  let writerOutcomeViolation;
387
527
  if (mapped.ok && input.task.writerOutcomePolicy) {
@@ -624,9 +764,7 @@ async function persistWriterGitBaseline(input) {
624
764
  nodeId: input.nodeId,
625
765
  phase: "pi-writer-before",
626
766
  capturedAt: new Date().toISOString(),
627
- statusSha256: createHash("sha256")
628
- .update(input.beforeStatus)
629
- .digest("hex"),
767
+ statusSha256: createHash("sha256").update(input.beforeStatus).digest("hex"),
630
768
  dirtyPaths: [...input.beforeSnapshot.keys()].sort(),
631
769
  pathFingerprintsSha256: createHash("sha256")
632
770
  .update(JSON.stringify(fingerprintEntries))
@@ -639,6 +777,40 @@ async function persistGitWriteGuardDiagnostics(runDir, nodeId, error) {
639
777
  return;
640
778
  await writeDagNodeJsonArtifact(runDir, nodeId, "git-write-guard-diagnostics.json", error.diagnostics);
641
779
  }
780
+ function boundedRecoveryErrorDetail(error) {
781
+ const detail = error instanceof Error ? error.message : String(error);
782
+ return detail.length <= 1000
783
+ ? detail
784
+ : `${detail.slice(0, 1000)}...[truncated]`;
785
+ }
786
+ /**
787
+ * Persist bounded run-owned evidence for a recognized repository-root `nul`
788
+ * candidate: candidate path, reason, action, result and recheck facts plus
789
+ * status hashes. Never records environment variables or secrets.
790
+ */
791
+ async function persistBenignToolArtifactRecovery(input) {
792
+ const artifact = {
793
+ schemaVersion: 1,
794
+ nodeId: input.nodeId,
795
+ candidatePath: input.evidence.candidatePath,
796
+ reason: input.evidence.reason,
797
+ action: input.evidence.action,
798
+ result: input.evidence.result,
799
+ ...(input.evidence.observedSizeBytes === undefined
800
+ ? {}
801
+ : { observedSizeBytes: input.evidence.observedSizeBytes }),
802
+ ...(input.evidence.errorDetail
803
+ ? { errorDetail: input.evidence.errorDetail }
804
+ : {}),
805
+ ...(input.evidence.removedAt
806
+ ? { removedAt: input.evidence.removedAt }
807
+ : {}),
808
+ beforeStatusSha256: input.evidence.beforeStatusSha256,
809
+ afterStatusSha256: input.evidence.afterStatusSha256,
810
+ recheck: input.evidence.recheck,
811
+ };
812
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "benign-tool-artifact-recovery.json", artifact);
813
+ }
642
814
  /**
643
815
  * Validate the writer's observed diff against its declared write boundary.
644
816
  * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff