@crewhaus/tool-lifecycle 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,7 +140,12 @@ verified.
140
140
  logs, the tool results the runtime saved to a file, or its proof bundles,
141
141
  as `to: .crewhaus` would — with no override and in every permission mode
142
142
  (0.8.0). The provability gate and `continuity.proof` read that record as
143
- evidence, so a migration onto it could rewrite the evidence;
143
+ evidence, so a migration onto it could rewrite the evidence. A copy that
144
+ would land in the files that control the run (its run files, settings, gate
145
+ records, environment files, compiled bundle and dependencies, Hangar's
146
+ directory), which decide what the agent
147
+ may do next, is refused the
148
+ same way;
144
149
  - a `targetVersion` different from the source's, and any `targetVersion` at all
145
150
  against a store with no version stamp. See below.
146
151
 
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ import { existsSync, lstatSync, readdirSync, statSync } from "node:fs";
50
50
  import * as path from "node:path";
51
51
  import { AUDIT_CHAIN_EXCLUSION_REASON, InvalidRetentionDateError, KNOWLEDGE_MARKER_RELPATH, RETENTION_CONFIG_RELPATH, RETIREMENT_LOG_FILENAME, RetentionConfigError, RetireError, SHARED_DIR_DEFAULT, applyPull, applyPush, buildRetirementPlan, formatPlan, fragmentContentHash, harnessOptedIn, openHarnessRecordStore, parseRetentionDate, planPull, planPush, readHarnessGraders, readHarnessMemories, readHarnessPrompts, runRetentionExport, runRetentionPurge, runRetentionSweep, runRetirement, } from "@crewhaus/harness-lifecycle";
52
52
  import { buildTool } from "@crewhaus/tool-builder";
53
- import { protectedRootHolding, protectedRootsOf, protectedWriteRefusal, } from "@crewhaus/tool-safety/fs";
53
+ import { protectedRootBeneath, protectedRootsOf, protectedWriteRefusal, } from "@crewhaus/tool-safety/fs";
54
54
  import { z } from "zod";
55
55
  import { NO_PII_PASS_REASON, PULLED_MEMORIES_RELPATH, SHARED_MANIFEST_RELPATH, SHARED_MEMORIES_RELPATH, auditHarnessMemoryFiles, auditSharedFragments, auditSharedMemories, harnessMemoryHashes, landedSharedGraderHashes, makeRedactor, pulledFragmentRelPath, sharedFragmentRelPath, } from "./lib/knowledge";
56
56
  import { COVERED_STORE_DIRS, EXPORT_MANIFEST_FILENAME, RECEIPT_FILENAME, RECORD_SHAPE_MIGRATION_UNAVAILABLE, inspectDestination, plannedPaths, readStoreVersion, uncoveredStateEntries, writeReceipt, writtenPaths, } from "./lib/migrate";
@@ -189,7 +189,7 @@ export const harnessRetire = buildTool({
189
189
  }),
190
190
  destructive: true,
191
191
  requireJustification: true,
192
- execute: async (input) => {
192
+ execute: async (input, ctx) => {
193
193
  const tool = "HarnessRetire";
194
194
  const dryRun = input.dryRun ?? true;
195
195
  if (!specNameIsSafe(input.spec)) {
@@ -206,6 +206,11 @@ export const harnessRetire = buildTool({
206
206
  if (!registry.ok)
207
207
  return refusal(tool, registry.code, registry.reason);
208
208
  const stateDir = path.join(harness.value.real, STATE_RELDIR);
209
+ // Retiring moves the state directory away, so a run cannot retire the harness
210
+ // it runs in. Refused for a preview too: it says what a real run would do.
211
+ if (protectedRootBeneath(stateDir, protectedRootsOf(ctx)) !== undefined) {
212
+ return protectedWriteRefusal(tool, `retiring ${dirRel}`);
213
+ }
209
214
  if (overlaps(archive.value.real, stateDir)) {
210
215
  return refusal(tool, "refused", `the archive directory and the state directory ${STATE_RELDIR} overlap — archiving a directory into itself would destroy it`);
211
216
  }
@@ -248,11 +253,7 @@ export const harnessRetire = buildTool({
248
253
  // symlink named like one of these would send the retirement log or the
249
254
  // fingerprint manifest out of the workspace — the two files that are the
250
255
  // whole evidence of the decommission.
251
- const archiveEscapes = containWritePaths(tool, archive.value, [
252
- STATE_MANIFEST_FILENAME,
253
- RETIREMENT_LOG_FILENAME,
254
- ARCHIVED_STATE_DIRNAME,
255
- ]);
256
+ const archiveEscapes = containWritePaths(tool, archive.value, [STATE_MANIFEST_FILENAME, RETIREMENT_LOG_FILENAME, ARCHIVED_STATE_DIRNAME], protectedRootsOf(ctx));
256
257
  if (archiveEscapes !== undefined) {
257
258
  return refusal(tool, archiveEscapes.code, archiveEscapes.reason);
258
259
  }
@@ -529,20 +530,14 @@ export const storeMigrate = buildTool({
529
530
  // the workspace — and a DANGLING one does not even register as a
530
531
  // conflict. Checked on the preview too, so the refusal is visible before
531
532
  // the run that would have written through it.
532
- const escaping = containWritePaths(tool, dest.value, written);
533
- if (escaping !== undefined)
534
- return refusal(tool, escaping.code, escaping.reason);
533
+ //
535
534
  // 0.8.0 — no copy lands in the run's own record (`RuntimeBridge
536
535
  // .protectedRoots`): a destination of `.crewhaus` would write
537
536
  // `.crewhaus/sessions/<id>.jsonl` over the live session log. Refused in
538
537
  // every mode, as StateImport and CopyPath refuse.
539
- const protectedRoots = protectedRootsOf(ctx);
540
- if (protectedRoots.length > 0) {
541
- const into = written.find((rel) => protectedRootHolding(path.join(dest.value.real, rel), protectedRoots) !== undefined);
542
- if (into !== undefined) {
543
- return protectedWriteRefusal(tool, `migrating onto ${input.to} (${into})`);
544
- }
545
- }
538
+ const escaping = containWritePaths(tool, dest.value, written, protectedRootsOf(ctx));
539
+ if (escaping !== undefined)
540
+ return refusal(tool, escaping.code, escaping.reason);
546
541
  const destState = inspectDestination(dest.value.real, written);
547
542
  if (!destState.ok)
548
543
  return refusal(tool, destState.code, destState.reason);
@@ -958,9 +953,10 @@ export const knowledgeSync = buildTool({
958
953
  }),
959
954
  destructive: true,
960
955
  requireJustification: true,
961
- execute: async (input) => {
956
+ execute: async (input, ctx) => {
962
957
  const tool = "KnowledgeSync";
963
958
  const dryRun = input.dryRun ?? true;
959
+ const protectedRoots = protectedRootsOf(ctx);
964
960
  const maxArtifacts = input.maxArtifacts ?? DEFAULT_MAX_ARTIFACTS;
965
961
  const harnessDir = containExistingDir(tool, input.dir ?? ".");
966
962
  if (!harnessDir.ok)
@@ -1080,7 +1076,7 @@ export const knowledgeSync = buildTool({
1080
1076
  SHARED_MANIFEST_RELPATH,
1081
1077
  ...plan.fragments.map(sharedFragmentRelPath),
1082
1078
  ];
1083
- const pushEscapes = containWritePaths(tool, shared.value, pushWrites);
1079
+ const pushEscapes = containWritePaths(tool, shared.value, pushWrites, protectedRoots);
1084
1080
  if (pushEscapes !== undefined) {
1085
1081
  return refusal(tool, pushEscapes.code, pushEscapes.reason, { ...common, plan: planReport });
1086
1082
  }
@@ -1180,7 +1176,7 @@ export const knowledgeSync = buildTool({
1180
1176
  ...(plan.memories.length > 0 ? [PULLED_MEMORIES_RELPATH] : []),
1181
1177
  ...plan.fragments.map(pulledFragmentRelPath),
1182
1178
  ];
1183
- const pullEscapes = containWritePaths(tool, harnessDir.value, pullWrites);
1179
+ const pullEscapes = containWritePaths(tool, harnessDir.value, pullWrites, protectedRoots);
1184
1180
  if (pullEscapes !== undefined) {
1185
1181
  return refusal(tool, pullEscapes.code, pullEscapes.reason, { ...common, plan: planReport });
1186
1182
  }
@@ -62,8 +62,12 @@ export declare function contain(toolName: string, rel: string): Loaded<SafePath>
62
62
  * through — symlink-aware, dangling links followed by hand — rather than a
63
63
  * second symlink walker written here. Returns the refusal, or `undefined`
64
64
  * when every write path stays inside the workspace.
65
+ *
66
+ * With `protectedRoots` (`RuntimeBridge.protectedRoots`, for a call that writes
67
+ * these paths), a path also has to land outside each of them. The landing place
68
+ * is the one `resolveSafe` worked out, which a dangling link's target counts in.
65
69
  */
66
- export declare function containWritePaths(toolName: string, root: SafePath, rels: ReadonlyArray<string>): Failure | undefined;
70
+ export declare function containWritePaths(toolName: string, root: SafePath, rels: ReadonlyArray<string>, protectedRoots?: readonly string[]): Failure | undefined;
67
71
  /** Resolve a directory that must already exist. */
68
72
  export declare function containExistingDir(toolName: string, rel: string): Loaded<SafePath>;
69
73
  /** True when `candidate` is `parent` or lives underneath it (both absolute). */
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { statSync } from "node:fs";
14
14
  import * as path from "node:path";
15
+ import { protectedRootHolding, protectedWriteRefusal } from "@crewhaus/tool-safety/fs";
15
16
  import { MAX_PATH_CHARS, ToolPermissionError, resolveSafe } from "../paths";
16
17
  /** Compact JSON — the reader is a model, and every byte is context. */
17
18
  export const json = (value) => JSON.stringify(value);
@@ -94,17 +95,25 @@ export function contain(toolName, rel) {
94
95
  * through — symlink-aware, dangling links followed by hand — rather than a
95
96
  * second symlink walker written here. Returns the refusal, or `undefined`
96
97
  * when every write path stays inside the workspace.
98
+ *
99
+ * With `protectedRoots` (`RuntimeBridge.protectedRoots`, for a call that writes
100
+ * these paths), a path also has to land outside each of them. The landing place
101
+ * is the one `resolveSafe` worked out, which a dangling link's target counts in.
97
102
  */
98
- export function containWritePaths(toolName, root, rels) {
103
+ export function containWritePaths(toolName, root, rels, protectedRoots = []) {
99
104
  const shownRoot = root.rel === "" ? "." : root.rel;
100
105
  for (const rel of rels) {
101
106
  const under = root.rel === "" ? rel : `${root.rel}/${rel}`;
102
107
  // Too long to open or create: it leads nowhere, so it is no escape.
103
108
  if (pastPathLimit(under))
104
109
  continue;
105
- if (!contain(toolName, under).ok) {
110
+ const resolved = contain(toolName, under);
111
+ if (!resolved.ok) {
106
112
  return fail("refused", `"${renderPath(rel)}" under "${renderPath(shownRoot)}" resolves outside the workspace root — a symlink on that path leads out, so the write would land on the link's target rather than inside "${renderPath(shownRoot)}". Remove the link and run again; there is no flag for writing through it.`);
107
113
  }
114
+ if (protectedRootHolding(resolved.value.real, protectedRoots) !== undefined) {
115
+ return fail("refused", protectedWriteRefusal(toolName, `"${renderPath(rel)}" under "${renderPath(shownRoot)}"`));
116
+ }
108
117
  }
109
118
  return undefined;
110
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-lifecycle",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic harness lifecycle: retirement, store migration, retention enforcement and knowledge sync",
6
6
  "main": "dist/index.js",
@@ -16,11 +16,11 @@
16
16
  "test": "bun test src"
17
17
  },
18
18
  "dependencies": {
19
- "@crewhaus/errors": "0.8.0",
20
- "@crewhaus/harness-lifecycle": "0.8.0",
21
- "@crewhaus/tool-builder": "0.8.0",
22
- "@crewhaus/tool-catalog": "0.8.0",
23
- "@crewhaus/tool-safety": "0.8.0",
19
+ "@crewhaus/errors": "0.8.1",
20
+ "@crewhaus/harness-lifecycle": "0.8.1",
21
+ "@crewhaus/tool-builder": "0.8.1",
22
+ "@crewhaus/tool-catalog": "0.8.1",
23
+ "@crewhaus/tool-safety": "0.8.1",
24
24
  "zod": "^3.23.8"
25
25
  },
26
26
  "devDependencies": {