@mc-and-his-agents/loom-installer 0.1.40 → 0.1.41

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 (22) hide show
  1. package/package.json +1 -1
  2. package/payload/manifest.json +42 -42
  3. package/payload/plugin/loom/skills/shared/scripts/loom_check.py +68 -0
  4. package/payload/plugin/loom/skills/shared/scripts/loom_flow.py +66 -1
  5. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py +68 -0
  6. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  7. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py +68 -0
  8. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  9. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_check.py +68 -0
  10. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  11. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py +68 -0
  12. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  13. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py +68 -0
  14. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  15. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py +68 -0
  16. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  17. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py +68 -0
  18. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  19. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_check.py +68 -0
  20. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py +66 -1
  21. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py +68 -0
  22. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py +66 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mc-and-his-agents/loom-installer",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Node installer for Loom plugin and single-skill installation surfaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,9 @@
2
2
  "schema_version": "loom-installer-payload/v1",
3
3
  "loom_version": "0.4.0",
4
4
  "source_repository": "https://github.com/MC-and-his-Agents/Loom",
5
- "source_commit": "59a29b0ae0fb79827af5bb3de6def984b132c398",
5
+ "source_commit": "348e5ac4d5c30e78a985865f38f792d40fd278fa",
6
6
  "source_ref": "main",
7
- "built_at": "2026-04-27T17:07:01+08:00",
7
+ "built_at": "2026-04-27T17:41:47+08:00",
8
8
  "runtime": {
9
9
  "python_minimum": "3.10",
10
10
  "python_recommended": "3.11+"
@@ -633,13 +633,13 @@
633
633
  },
634
634
  {
635
635
  "path": "plugin/loom/skills/shared/scripts/loom_check.py",
636
- "bytes": 344185,
637
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
636
+ "bytes": 347978,
637
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
638
638
  },
639
639
  {
640
640
  "path": "plugin/loom/skills/shared/scripts/loom_flow.py",
641
- "bytes": 335994,
642
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
641
+ "bytes": 338788,
642
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
643
643
  },
644
644
  {
645
645
  "path": "plugin/loom/skills/shared/scripts/loom_init.py",
@@ -1223,13 +1223,13 @@
1223
1223
  },
1224
1224
  {
1225
1225
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py",
1226
- "bytes": 344185,
1227
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
1226
+ "bytes": 347978,
1227
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
1228
1228
  },
1229
1229
  {
1230
1230
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py",
1231
- "bytes": 335994,
1232
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
1231
+ "bytes": 338788,
1232
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
1233
1233
  },
1234
1234
  {
1235
1235
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_init.py",
@@ -1843,13 +1843,13 @@
1843
1843
  },
1844
1844
  {
1845
1845
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py",
1846
- "bytes": 344185,
1847
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
1846
+ "bytes": 347978,
1847
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
1848
1848
  },
1849
1849
  {
1850
1850
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py",
1851
- "bytes": 335994,
1852
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
1851
+ "bytes": 338788,
1852
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
1853
1853
  },
1854
1854
  {
1855
1855
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_init.py",
@@ -2463,13 +2463,13 @@
2463
2463
  },
2464
2464
  {
2465
2465
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_check.py",
2466
- "bytes": 344185,
2467
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
2466
+ "bytes": 347978,
2467
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
2468
2468
  },
2469
2469
  {
2470
2470
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py",
2471
- "bytes": 335994,
2472
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
2471
+ "bytes": 338788,
2472
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
2473
2473
  },
2474
2474
  {
2475
2475
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_init.py",
@@ -3088,13 +3088,13 @@
3088
3088
  },
3089
3089
  {
3090
3090
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py",
3091
- "bytes": 344185,
3092
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
3091
+ "bytes": 347978,
3092
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
3093
3093
  },
3094
3094
  {
3095
3095
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py",
3096
- "bytes": 335994,
3097
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
3096
+ "bytes": 338788,
3097
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
3098
3098
  },
3099
3099
  {
3100
3100
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_init.py",
@@ -3708,13 +3708,13 @@
3708
3708
  },
3709
3709
  {
3710
3710
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py",
3711
- "bytes": 344185,
3712
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
3711
+ "bytes": 347978,
3712
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
3713
3713
  },
3714
3714
  {
3715
3715
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py",
3716
- "bytes": 335994,
3717
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
3716
+ "bytes": 338788,
3717
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
3718
3718
  },
3719
3719
  {
3720
3720
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -4328,13 +4328,13 @@
4328
4328
  },
4329
4329
  {
4330
4330
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py",
4331
- "bytes": 344185,
4332
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
4331
+ "bytes": 347978,
4332
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
4333
4333
  },
4334
4334
  {
4335
4335
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py",
4336
- "bytes": 335994,
4337
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
4336
+ "bytes": 338788,
4337
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
4338
4338
  },
4339
4339
  {
4340
4340
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_init.py",
@@ -4948,13 +4948,13 @@
4948
4948
  },
4949
4949
  {
4950
4950
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py",
4951
- "bytes": 344185,
4952
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
4951
+ "bytes": 347978,
4952
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
4953
4953
  },
4954
4954
  {
4955
4955
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py",
4956
- "bytes": 335994,
4957
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
4956
+ "bytes": 338788,
4957
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
4958
4958
  },
4959
4959
  {
4960
4960
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_init.py",
@@ -5568,13 +5568,13 @@
5568
5568
  },
5569
5569
  {
5570
5570
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_check.py",
5571
- "bytes": 344185,
5572
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
5571
+ "bytes": 347978,
5572
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
5573
5573
  },
5574
5574
  {
5575
5575
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py",
5576
- "bytes": 335994,
5577
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
5576
+ "bytes": 338788,
5577
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
5578
5578
  },
5579
5579
  {
5580
5580
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -6188,13 +6188,13 @@
6188
6188
  },
6189
6189
  {
6190
6190
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py",
6191
- "bytes": 344185,
6192
- "sha256": "5fbb3018d4ac1764d711bca30f5d4ce1a0466015856a99c59fa4a1d25f732a7b"
6191
+ "bytes": 347978,
6192
+ "sha256": "f5119cf04e195c79dfeb58c33c5d4c8e524c2eb2b162b5906cc04a655465543e"
6193
6193
  },
6194
6194
  {
6195
6195
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py",
6196
- "bytes": 335994,
6197
- "sha256": "2a0e0c49c268e6d00cffbabc11f985a00b7a700adbe7bb6efa4f2da7c08c10fd"
6196
+ "bytes": 338788,
6197
+ "sha256": "e5f386988863d970ebf5415bfdb3a91159b120a84d3acd82ca3435be6f9af19c"
6198
6198
  },
6199
6199
  {
6200
6200
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -3690,6 +3690,42 @@ def check_daily_execution_cli(root: Path) -> list[Failure]:
3690
3690
  elif payload.get("result") != "pass":
3691
3691
  failures.append(Failure("daily-execution-cli", "`work-item update` must pass on a clean temp copy"))
3692
3692
 
3693
+ poisoned_authoring_target = temp_root / "authoring-poisoned-workspace"
3694
+ shutil.copytree(authoring_target, poisoned_authoring_target)
3695
+ poisoned_work_item = poisoned_authoring_target / ".loom/work-items/NEXT-0001.md"
3696
+ poisoned_before = poisoned_work_item.read_text(encoding="utf-8").replace(
3697
+ "- Workspace Entry: .",
3698
+ "- Workspace Entry: ../outside.md",
3699
+ )
3700
+ poisoned_work_item.write_text(poisoned_before, encoding="utf-8")
3701
+ poisoned_init = poisoned_authoring_target / ".loom/bootstrap/init-result.json"
3702
+ poisoned_init_before = load_json_file(poisoned_init)
3703
+ payload, error = load_command_json(
3704
+ root,
3705
+ [
3706
+ "python3",
3707
+ "tools/loom_flow.py",
3708
+ "work-item",
3709
+ "update",
3710
+ "--target",
3711
+ str(poisoned_authoring_target),
3712
+ "--item",
3713
+ "NEXT-0001",
3714
+ "--scope",
3715
+ "This update must not persist because the workspace locator is unsafe",
3716
+ "--activate",
3717
+ ],
3718
+ )
3719
+ poisoned_init_after = load_json_file(poisoned_init)
3720
+ if error:
3721
+ failures.append(Failure("daily-execution-cli", f"`work-item update --activate` poisoned workspace sample failed: {error}"))
3722
+ elif payload.get("result") != "block":
3723
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must block poisoned workspace locators"))
3724
+ elif poisoned_work_item.read_text(encoding="utf-8") != poisoned_before:
3725
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not rewrite a poisoned Work Item before locator validation passes"))
3726
+ elif poisoned_init_after.get("fact_chain", {}).get("entry_points") != poisoned_init_before.get("fact_chain", {}).get("entry_points"):
3727
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not mutate active fact-chain locators when locator validation blocks"))
3728
+
3693
3729
  payload, error = load_command_json(
3694
3730
  root,
3695
3731
  [
@@ -6678,6 +6714,38 @@ def check_adversarial_adoption_fixture(root: Path) -> list[Failure]:
6678
6714
  elif escape_work_item_payload.get("result") != "block":
6679
6715
  failures.append(Failure("adversarial-adoption", "work-item create must block recovery locators that escape the target root"))
6680
6716
 
6717
+ poisoned_work_item_target = base / "work-item-update-poisoned-locator"
6718
+ shutil.copytree(baseline, poisoned_work_item_target)
6719
+ poisoned_work_item_path = poisoned_work_item_target / ".loom/work-items/INIT-0001.md"
6720
+ poisoned_work_item_text = poisoned_work_item_path.read_text(encoding="utf-8").replace(
6721
+ "- Recovery Entry: .loom/progress/INIT-0001.md",
6722
+ "- Recovery Entry: ../outside.md",
6723
+ )
6724
+ poisoned_work_item_path.write_text(poisoned_work_item_text, encoding="utf-8")
6725
+ poisoned_init_path = poisoned_work_item_target / ".loom/bootstrap/init-result.json"
6726
+ poisoned_init_before = poisoned_init_path.read_text(encoding="utf-8")
6727
+ poisoned_update_payload, error = load_command_json(
6728
+ root,
6729
+ [
6730
+ "python3",
6731
+ "tools/loom_flow.py",
6732
+ "work-item",
6733
+ "update",
6734
+ "--target",
6735
+ str(poisoned_work_item_target),
6736
+ "--item",
6737
+ "INIT-0001",
6738
+ "--activate",
6739
+ ],
6740
+ )
6741
+ poisoned_init_after = poisoned_init_path.read_text(encoding="utf-8")
6742
+ if error:
6743
+ failures.append(Failure("adversarial-adoption", f"poisoned work-item update sample failed: {error}"))
6744
+ elif poisoned_update_payload.get("result") != "block":
6745
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must block poisoned recovery locators"))
6746
+ elif poisoned_init_before != poisoned_init_after:
6747
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must not mutate init-result before locator validation passes"))
6748
+
6681
6749
  shadow_escape_target = base / "shadow-locator-escape"
6682
6750
  shutil.copytree(baseline, shadow_escape_target)
6683
6751
  interop_path = shadow_escape_target / ".loom/companion/interop.json"
@@ -6975,6 +6975,39 @@ def update_active_entry_points(
6975
6975
  output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
6976
6976
 
6977
6977
 
6978
+ def validate_work_item_payload_locators(
6979
+ target_root: Path,
6980
+ work_item_payload: dict[str, Any],
6981
+ ) -> tuple[dict[str, Path], list[str]]:
6982
+ """Validate final authored locator truth before any Work Item carrier write."""
6983
+ locators: dict[str, str] = {
6984
+ "recovery_entry": str(work_item_payload.get("recovery_entry", "")),
6985
+ "review_entry": str(work_item_payload.get("review_entry", "")),
6986
+ }
6987
+ associated_artifacts = work_item_payload.get("associated_artifacts", [])
6988
+ if isinstance(associated_artifacts, list):
6989
+ for index, artifact in enumerate(associated_artifacts, start=1):
6990
+ locators[f"associated_artifacts[{index}]"] = str(artifact)
6991
+ else:
6992
+ return {}, ["associated_artifacts must be a list of repo-relative locators"]
6993
+
6994
+ resolved: dict[str, Path] = {}
6995
+ errors: list[str] = []
6996
+ workspace_path, workspace_errors = resolve_workspace_path(
6997
+ target_root,
6998
+ str(work_item_payload.get("workspace_entry", "")),
6999
+ )
7000
+ errors.extend(f"work item workspace_entry: {message}" for message in workspace_errors)
7001
+ if workspace_path is not None:
7002
+ resolved["workspace_entry"] = workspace_path
7003
+ for label, locator in locators.items():
7004
+ path, locator_errors = resolve_repo_relative_path(target_root, locator, label=f"work item {label}")
7005
+ errors.extend(locator_errors)
7006
+ if path is not None:
7007
+ resolved[label] = path
7008
+ return resolved, errors
7009
+
7010
+
6978
7011
  def handle_work_item(args: argparse.Namespace) -> int:
6979
7012
  target_root = Path(args.target).expanduser().resolve()
6980
7013
  output_path, output_errors = resolve_repo_relative_path(target_root, args.output, label="init-result locator")
@@ -7090,6 +7123,23 @@ def handle_work_item(args: argparse.Namespace) -> int:
7090
7123
  "closing_condition": args.closing_condition,
7091
7124
  "associated_artifacts": deduped_artifacts,
7092
7125
  }
7126
+ resolved_payload_locators, payload_locator_errors = validate_work_item_payload_locators(
7127
+ target_root,
7128
+ work_item_payload,
7129
+ )
7130
+ if payload_locator_errors:
7131
+ return emit(
7132
+ {
7133
+ "command": "work-item",
7134
+ "operation": "create",
7135
+ "result": "block",
7136
+ "summary": "work-item create refused unsafe authored locator input.",
7137
+ "missing_inputs": payload_locator_errors,
7138
+ "fallback_to": "admission",
7139
+ }
7140
+ )
7141
+ recovery_path = resolved_payload_locators["recovery_entry"]
7142
+ review_path = resolved_payload_locators["review_entry"]
7093
7143
  work_item_path.parent.mkdir(parents=True, exist_ok=True)
7094
7144
  work_item_path.write_text(render_work_item(work_item_payload), encoding="utf-8")
7095
7145
  review_path.parent.mkdir(parents=True, exist_ok=True)
@@ -7199,7 +7249,22 @@ def handle_work_item(args: argparse.Namespace) -> int:
7199
7249
  entry for entry in work_item_payload["associated_artifacts"] if entry != artifact
7200
7250
  ]
7201
7251
  recovery_relative = work_item_payload["recovery_entry"]
7202
- recovery_path = target_root / recovery_relative
7252
+ resolved_payload_locators, payload_locator_errors = validate_work_item_payload_locators(
7253
+ target_root,
7254
+ work_item_payload,
7255
+ )
7256
+ if payload_locator_errors:
7257
+ return emit(
7258
+ {
7259
+ "command": "work-item",
7260
+ "operation": "update",
7261
+ "result": "block",
7262
+ "summary": "work-item update refused unsafe authored locator input.",
7263
+ "missing_inputs": payload_locator_errors,
7264
+ "fallback_to": "admission",
7265
+ }
7266
+ )
7267
+ recovery_path = resolved_payload_locators["recovery_entry"]
7203
7268
  work_item_path.write_text(render_work_item(work_item_payload), encoding="utf-8")
7204
7269
 
7205
7270
  if args.activate:
@@ -3690,6 +3690,42 @@ def check_daily_execution_cli(root: Path) -> list[Failure]:
3690
3690
  elif payload.get("result") != "pass":
3691
3691
  failures.append(Failure("daily-execution-cli", "`work-item update` must pass on a clean temp copy"))
3692
3692
 
3693
+ poisoned_authoring_target = temp_root / "authoring-poisoned-workspace"
3694
+ shutil.copytree(authoring_target, poisoned_authoring_target)
3695
+ poisoned_work_item = poisoned_authoring_target / ".loom/work-items/NEXT-0001.md"
3696
+ poisoned_before = poisoned_work_item.read_text(encoding="utf-8").replace(
3697
+ "- Workspace Entry: .",
3698
+ "- Workspace Entry: ../outside.md",
3699
+ )
3700
+ poisoned_work_item.write_text(poisoned_before, encoding="utf-8")
3701
+ poisoned_init = poisoned_authoring_target / ".loom/bootstrap/init-result.json"
3702
+ poisoned_init_before = load_json_file(poisoned_init)
3703
+ payload, error = load_command_json(
3704
+ root,
3705
+ [
3706
+ "python3",
3707
+ "tools/loom_flow.py",
3708
+ "work-item",
3709
+ "update",
3710
+ "--target",
3711
+ str(poisoned_authoring_target),
3712
+ "--item",
3713
+ "NEXT-0001",
3714
+ "--scope",
3715
+ "This update must not persist because the workspace locator is unsafe",
3716
+ "--activate",
3717
+ ],
3718
+ )
3719
+ poisoned_init_after = load_json_file(poisoned_init)
3720
+ if error:
3721
+ failures.append(Failure("daily-execution-cli", f"`work-item update --activate` poisoned workspace sample failed: {error}"))
3722
+ elif payload.get("result") != "block":
3723
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must block poisoned workspace locators"))
3724
+ elif poisoned_work_item.read_text(encoding="utf-8") != poisoned_before:
3725
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not rewrite a poisoned Work Item before locator validation passes"))
3726
+ elif poisoned_init_after.get("fact_chain", {}).get("entry_points") != poisoned_init_before.get("fact_chain", {}).get("entry_points"):
3727
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not mutate active fact-chain locators when locator validation blocks"))
3728
+
3693
3729
  payload, error = load_command_json(
3694
3730
  root,
3695
3731
  [
@@ -6678,6 +6714,38 @@ def check_adversarial_adoption_fixture(root: Path) -> list[Failure]:
6678
6714
  elif escape_work_item_payload.get("result") != "block":
6679
6715
  failures.append(Failure("adversarial-adoption", "work-item create must block recovery locators that escape the target root"))
6680
6716
 
6717
+ poisoned_work_item_target = base / "work-item-update-poisoned-locator"
6718
+ shutil.copytree(baseline, poisoned_work_item_target)
6719
+ poisoned_work_item_path = poisoned_work_item_target / ".loom/work-items/INIT-0001.md"
6720
+ poisoned_work_item_text = poisoned_work_item_path.read_text(encoding="utf-8").replace(
6721
+ "- Recovery Entry: .loom/progress/INIT-0001.md",
6722
+ "- Recovery Entry: ../outside.md",
6723
+ )
6724
+ poisoned_work_item_path.write_text(poisoned_work_item_text, encoding="utf-8")
6725
+ poisoned_init_path = poisoned_work_item_target / ".loom/bootstrap/init-result.json"
6726
+ poisoned_init_before = poisoned_init_path.read_text(encoding="utf-8")
6727
+ poisoned_update_payload, error = load_command_json(
6728
+ root,
6729
+ [
6730
+ "python3",
6731
+ "tools/loom_flow.py",
6732
+ "work-item",
6733
+ "update",
6734
+ "--target",
6735
+ str(poisoned_work_item_target),
6736
+ "--item",
6737
+ "INIT-0001",
6738
+ "--activate",
6739
+ ],
6740
+ )
6741
+ poisoned_init_after = poisoned_init_path.read_text(encoding="utf-8")
6742
+ if error:
6743
+ failures.append(Failure("adversarial-adoption", f"poisoned work-item update sample failed: {error}"))
6744
+ elif poisoned_update_payload.get("result") != "block":
6745
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must block poisoned recovery locators"))
6746
+ elif poisoned_init_before != poisoned_init_after:
6747
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must not mutate init-result before locator validation passes"))
6748
+
6681
6749
  shadow_escape_target = base / "shadow-locator-escape"
6682
6750
  shutil.copytree(baseline, shadow_escape_target)
6683
6751
  interop_path = shadow_escape_target / ".loom/companion/interop.json"
@@ -6975,6 +6975,39 @@ def update_active_entry_points(
6975
6975
  output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
6976
6976
 
6977
6977
 
6978
+ def validate_work_item_payload_locators(
6979
+ target_root: Path,
6980
+ work_item_payload: dict[str, Any],
6981
+ ) -> tuple[dict[str, Path], list[str]]:
6982
+ """Validate final authored locator truth before any Work Item carrier write."""
6983
+ locators: dict[str, str] = {
6984
+ "recovery_entry": str(work_item_payload.get("recovery_entry", "")),
6985
+ "review_entry": str(work_item_payload.get("review_entry", "")),
6986
+ }
6987
+ associated_artifacts = work_item_payload.get("associated_artifacts", [])
6988
+ if isinstance(associated_artifacts, list):
6989
+ for index, artifact in enumerate(associated_artifacts, start=1):
6990
+ locators[f"associated_artifacts[{index}]"] = str(artifact)
6991
+ else:
6992
+ return {}, ["associated_artifacts must be a list of repo-relative locators"]
6993
+
6994
+ resolved: dict[str, Path] = {}
6995
+ errors: list[str] = []
6996
+ workspace_path, workspace_errors = resolve_workspace_path(
6997
+ target_root,
6998
+ str(work_item_payload.get("workspace_entry", "")),
6999
+ )
7000
+ errors.extend(f"work item workspace_entry: {message}" for message in workspace_errors)
7001
+ if workspace_path is not None:
7002
+ resolved["workspace_entry"] = workspace_path
7003
+ for label, locator in locators.items():
7004
+ path, locator_errors = resolve_repo_relative_path(target_root, locator, label=f"work item {label}")
7005
+ errors.extend(locator_errors)
7006
+ if path is not None:
7007
+ resolved[label] = path
7008
+ return resolved, errors
7009
+
7010
+
6978
7011
  def handle_work_item(args: argparse.Namespace) -> int:
6979
7012
  target_root = Path(args.target).expanduser().resolve()
6980
7013
  output_path, output_errors = resolve_repo_relative_path(target_root, args.output, label="init-result locator")
@@ -7090,6 +7123,23 @@ def handle_work_item(args: argparse.Namespace) -> int:
7090
7123
  "closing_condition": args.closing_condition,
7091
7124
  "associated_artifacts": deduped_artifacts,
7092
7125
  }
7126
+ resolved_payload_locators, payload_locator_errors = validate_work_item_payload_locators(
7127
+ target_root,
7128
+ work_item_payload,
7129
+ )
7130
+ if payload_locator_errors:
7131
+ return emit(
7132
+ {
7133
+ "command": "work-item",
7134
+ "operation": "create",
7135
+ "result": "block",
7136
+ "summary": "work-item create refused unsafe authored locator input.",
7137
+ "missing_inputs": payload_locator_errors,
7138
+ "fallback_to": "admission",
7139
+ }
7140
+ )
7141
+ recovery_path = resolved_payload_locators["recovery_entry"]
7142
+ review_path = resolved_payload_locators["review_entry"]
7093
7143
  work_item_path.parent.mkdir(parents=True, exist_ok=True)
7094
7144
  work_item_path.write_text(render_work_item(work_item_payload), encoding="utf-8")
7095
7145
  review_path.parent.mkdir(parents=True, exist_ok=True)
@@ -7199,7 +7249,22 @@ def handle_work_item(args: argparse.Namespace) -> int:
7199
7249
  entry for entry in work_item_payload["associated_artifacts"] if entry != artifact
7200
7250
  ]
7201
7251
  recovery_relative = work_item_payload["recovery_entry"]
7202
- recovery_path = target_root / recovery_relative
7252
+ resolved_payload_locators, payload_locator_errors = validate_work_item_payload_locators(
7253
+ target_root,
7254
+ work_item_payload,
7255
+ )
7256
+ if payload_locator_errors:
7257
+ return emit(
7258
+ {
7259
+ "command": "work-item",
7260
+ "operation": "update",
7261
+ "result": "block",
7262
+ "summary": "work-item update refused unsafe authored locator input.",
7263
+ "missing_inputs": payload_locator_errors,
7264
+ "fallback_to": "admission",
7265
+ }
7266
+ )
7267
+ recovery_path = resolved_payload_locators["recovery_entry"]
7203
7268
  work_item_path.write_text(render_work_item(work_item_payload), encoding="utf-8")
7204
7269
 
7205
7270
  if args.activate:
@@ -3690,6 +3690,42 @@ def check_daily_execution_cli(root: Path) -> list[Failure]:
3690
3690
  elif payload.get("result") != "pass":
3691
3691
  failures.append(Failure("daily-execution-cli", "`work-item update` must pass on a clean temp copy"))
3692
3692
 
3693
+ poisoned_authoring_target = temp_root / "authoring-poisoned-workspace"
3694
+ shutil.copytree(authoring_target, poisoned_authoring_target)
3695
+ poisoned_work_item = poisoned_authoring_target / ".loom/work-items/NEXT-0001.md"
3696
+ poisoned_before = poisoned_work_item.read_text(encoding="utf-8").replace(
3697
+ "- Workspace Entry: .",
3698
+ "- Workspace Entry: ../outside.md",
3699
+ )
3700
+ poisoned_work_item.write_text(poisoned_before, encoding="utf-8")
3701
+ poisoned_init = poisoned_authoring_target / ".loom/bootstrap/init-result.json"
3702
+ poisoned_init_before = load_json_file(poisoned_init)
3703
+ payload, error = load_command_json(
3704
+ root,
3705
+ [
3706
+ "python3",
3707
+ "tools/loom_flow.py",
3708
+ "work-item",
3709
+ "update",
3710
+ "--target",
3711
+ str(poisoned_authoring_target),
3712
+ "--item",
3713
+ "NEXT-0001",
3714
+ "--scope",
3715
+ "This update must not persist because the workspace locator is unsafe",
3716
+ "--activate",
3717
+ ],
3718
+ )
3719
+ poisoned_init_after = load_json_file(poisoned_init)
3720
+ if error:
3721
+ failures.append(Failure("daily-execution-cli", f"`work-item update --activate` poisoned workspace sample failed: {error}"))
3722
+ elif payload.get("result") != "block":
3723
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must block poisoned workspace locators"))
3724
+ elif poisoned_work_item.read_text(encoding="utf-8") != poisoned_before:
3725
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not rewrite a poisoned Work Item before locator validation passes"))
3726
+ elif poisoned_init_after.get("fact_chain", {}).get("entry_points") != poisoned_init_before.get("fact_chain", {}).get("entry_points"):
3727
+ failures.append(Failure("daily-execution-cli", "`work-item update --activate` must not mutate active fact-chain locators when locator validation blocks"))
3728
+
3693
3729
  payload, error = load_command_json(
3694
3730
  root,
3695
3731
  [
@@ -6678,6 +6714,38 @@ def check_adversarial_adoption_fixture(root: Path) -> list[Failure]:
6678
6714
  elif escape_work_item_payload.get("result") != "block":
6679
6715
  failures.append(Failure("adversarial-adoption", "work-item create must block recovery locators that escape the target root"))
6680
6716
 
6717
+ poisoned_work_item_target = base / "work-item-update-poisoned-locator"
6718
+ shutil.copytree(baseline, poisoned_work_item_target)
6719
+ poisoned_work_item_path = poisoned_work_item_target / ".loom/work-items/INIT-0001.md"
6720
+ poisoned_work_item_text = poisoned_work_item_path.read_text(encoding="utf-8").replace(
6721
+ "- Recovery Entry: .loom/progress/INIT-0001.md",
6722
+ "- Recovery Entry: ../outside.md",
6723
+ )
6724
+ poisoned_work_item_path.write_text(poisoned_work_item_text, encoding="utf-8")
6725
+ poisoned_init_path = poisoned_work_item_target / ".loom/bootstrap/init-result.json"
6726
+ poisoned_init_before = poisoned_init_path.read_text(encoding="utf-8")
6727
+ poisoned_update_payload, error = load_command_json(
6728
+ root,
6729
+ [
6730
+ "python3",
6731
+ "tools/loom_flow.py",
6732
+ "work-item",
6733
+ "update",
6734
+ "--target",
6735
+ str(poisoned_work_item_target),
6736
+ "--item",
6737
+ "INIT-0001",
6738
+ "--activate",
6739
+ ],
6740
+ )
6741
+ poisoned_init_after = poisoned_init_path.read_text(encoding="utf-8")
6742
+ if error:
6743
+ failures.append(Failure("adversarial-adoption", f"poisoned work-item update sample failed: {error}"))
6744
+ elif poisoned_update_payload.get("result") != "block":
6745
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must block poisoned recovery locators"))
6746
+ elif poisoned_init_before != poisoned_init_after:
6747
+ failures.append(Failure("adversarial-adoption", "work-item update --activate must not mutate init-result before locator validation passes"))
6748
+
6681
6749
  shadow_escape_target = base / "shadow-locator-escape"
6682
6750
  shutil.copytree(baseline, shadow_escape_target)
6683
6751
  interop_path = shadow_escape_target / ".loom/companion/interop.json"