open-multi-agent-kit 0.98.1 → 0.98.3

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 (86) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -2
  3. package/dist/cli/mcp-attach.d.ts +15 -0
  4. package/dist/cli/mcp-attach.d.ts.map +1 -0
  5. package/dist/cli/mcp-attach.js +14 -0
  6. package/dist/cli/mcp-attach.js.map +1 -0
  7. package/dist/core/advisory-judge-model.d.ts.map +1 -1
  8. package/dist/core/advisory-judge-model.js +8 -1
  9. package/dist/core/advisory-judge-model.js.map +1 -1
  10. package/dist/core/advisory-judge-types.d.ts +18 -1
  11. package/dist/core/advisory-judge-types.d.ts.map +1 -1
  12. package/dist/core/advisory-judge-types.js.map +1 -1
  13. package/dist/core/advisory-judge.d.ts.map +1 -1
  14. package/dist/core/advisory-judge.js +41 -6
  15. package/dist/core/advisory-judge.js.map +1 -1
  16. package/dist/core/agent-session.d.ts +7 -0
  17. package/dist/core/agent-session.d.ts.map +1 -1
  18. package/dist/core/agent-session.js +66 -14
  19. package/dist/core/agent-session.js.map +1 -1
  20. package/dist/core/auth-storage.d.ts +19 -2
  21. package/dist/core/auth-storage.d.ts.map +1 -1
  22. package/dist/core/auth-storage.js +57 -6
  23. package/dist/core/auth-storage.js.map +1 -1
  24. package/dist/core/compaction/oauth-recovery.d.ts +29 -0
  25. package/dist/core/compaction/oauth-recovery.d.ts.map +1 -0
  26. package/dist/core/compaction/oauth-recovery.js +40 -0
  27. package/dist/core/compaction/oauth-recovery.js.map +1 -0
  28. package/dist/core/model-registry.d.ts +10 -1
  29. package/dist/core/model-registry.d.ts.map +1 -1
  30. package/dist/core/model-registry.js +16 -2
  31. package/dist/core/model-registry.js.map +1 -1
  32. package/dist/core/provider-resilience.d.ts +8 -0
  33. package/dist/core/provider-resilience.d.ts.map +1 -1
  34. package/dist/core/provider-resilience.js +17 -0
  35. package/dist/core/provider-resilience.js.map +1 -1
  36. package/dist/core/provider-usage.d.ts +1 -1
  37. package/dist/core/provider-usage.d.ts.map +1 -1
  38. package/dist/core/provider-usage.js +3 -2
  39. package/dist/core/provider-usage.js.map +1 -1
  40. package/dist/core/session-bash-runtime.d.ts +7 -0
  41. package/dist/core/session-bash-runtime.d.ts.map +1 -1
  42. package/dist/core/session-bash-runtime.js +10 -1
  43. package/dist/core/session-bash-runtime.js.map +1 -1
  44. package/dist/core/session-failure-cause.d.ts.map +1 -1
  45. package/dist/core/session-failure-cause.js +7 -1
  46. package/dist/core/session-failure-cause.js.map +1 -1
  47. package/dist/core/session-termination.d.ts.map +1 -1
  48. package/dist/core/session-termination.js +3 -1
  49. package/dist/core/session-termination.js.map +1 -1
  50. package/dist/core/verified-bash-runtime.d.ts +40 -0
  51. package/dist/core/verified-bash-runtime.d.ts.map +1 -1
  52. package/dist/core/verified-bash-runtime.js +66 -17
  53. package/dist/core/verified-bash-runtime.js.map +1 -1
  54. package/dist/guardrails/workspace-fingerprint.d.ts +2 -0
  55. package/dist/guardrails/workspace-fingerprint.d.ts.map +1 -1
  56. package/dist/guardrails/workspace-fingerprint.js +12 -10
  57. package/dist/guardrails/workspace-fingerprint.js.map +1 -1
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/main.d.ts.map +1 -1
  63. package/dist/main.js +7 -0
  64. package/dist/main.js.map +1 -1
  65. package/docs/advisory-selection.md +112 -0
  66. package/docs/book-to-skill.md +17 -0
  67. package/docs/docs.json +4 -0
  68. package/docs/index.md +2 -0
  69. package/docs/mcp.md +10 -2
  70. package/docs/release-audit-0.98.3.md +97 -0
  71. package/docs/run-protocol.md +3 -1
  72. package/docs/runtime-algorithms.md +19 -2
  73. package/docs/sdk.md +24 -1
  74. package/docs/security.md +4 -9
  75. package/examples/extensions/custom-provider-anthropic/index.ts +4 -1
  76. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  77. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  78. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  79. package/examples/extensions/gondolin/package-lock.json +2 -2
  80. package/examples/extensions/gondolin/package.json +1 -1
  81. package/examples/extensions/sandbox/package-lock.json +2 -2
  82. package/examples/extensions/sandbox/package.json +1 -1
  83. package/examples/extensions/with-deps/package-lock.json +2 -2
  84. package/examples/extensions/with-deps/package.json +1 -1
  85. package/npm-shrinkwrap.json +18 -18
  86. package/package.json +6 -6
@@ -1,5 +1,35 @@
1
1
  import type { WorkspaceScope } from "../types/evidence.ts";
2
2
  export declare function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean;
3
+ /**
4
+ * Why a session scope is not a complete view of the workspace dirty set.
5
+ *
6
+ * `unavailable` is not `complete`: outside a worktree, or when git cannot be
7
+ * read, nothing was enumerated, so an empty artifact set is an absence of
8
+ * evidence rather than evidence of a clean tree.
9
+ */
10
+ export type SessionScopeCompleteness = "complete" | "partial_truncated" | "partial_excluded" | "unavailable";
11
+ /**
12
+ * A session scope plus what it could not bind.
13
+ *
14
+ * The scope drops dirty paths two ways — a hard cap for availability, and the
15
+ * normalized-path filter the receipt parser forces — and both were previously
16
+ * silent, so a receipt built on a partial view read exactly like one that saw
17
+ * the whole workspace. Dropping stays deliberate; hiding it was the defect.
18
+ */
19
+ export interface SessionWorkspaceScopeReport {
20
+ readonly scope: WorkspaceScope;
21
+ /** Unique dirty entries git reported, before the cap and the path filter. */
22
+ readonly totalDirtyPathCount: number;
23
+ /** Entries the scope binds: always `scope.artifactPaths.length`. */
24
+ readonly selectedPathCount: number;
25
+ /** Unique dirty entries no receipt can bind, such as a name carrying a backslash. */
26
+ readonly excludedPathCount: number;
27
+ /** True when the cap, not the filter, kept an eligible path out of the scope. */
28
+ readonly truncated: boolean;
29
+ readonly completeness: SessionScopeCompleteness;
30
+ /** Digest of the sorted excluded set, so two different losses are distinguishable. Absent when nothing was excluded. */
31
+ readonly excludedPathSetSha256?: string;
32
+ }
3
33
  /**
4
34
  * Workspace scope for session bash receipts. Inside a git worktree the scope
5
35
  * root is the toplevel and `artifactPaths` is the current dirty set (staged,
@@ -10,4 +40,14 @@ export declare function isVerifiedBashEnabled(env?: Record<string, string | unde
10
40
  export declare function resolveSessionWorkspaceScope(cwd: string, options?: {
11
41
  maxPaths?: number;
12
42
  }): WorkspaceScope;
43
+ /**
44
+ * The scope a receipt would bind, together with the completeness facts that say
45
+ * whether binding it proves anything about the whole workspace.
46
+ *
47
+ * Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too
48
+ * keeps a capped probe from serving a later full request a truncated answer.
49
+ */
50
+ export declare function resolveSessionWorkspaceScopeReport(cwd: string, options?: {
51
+ maxPaths?: number;
52
+ }): SessionWorkspaceScopeReport;
13
53
  //# sourceMappingURL=verified-bash-runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAGvF;AAaD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAUzG","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n\nconst scopeCache = new Map<string, { at: number; scope: WorkspaceScope }>();\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\tconst cached = scopeCache.get(cwd);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.scope;\n\tconst scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(cwd, { at: Date.now(), scope });\n\treturn scope;\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): WorkspaceScope {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return { root: cwd, artifactPaths: [] };\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return { root: cwd, artifactPaths: [] };\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return { root, artifactPaths: [] };\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths: string[] = [];\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Trailing-slash entries survive -uall only for untracked nested\n\t\t\t// repositories; their contents are outside the parent's dirty digest\n\t\t\t// and cannot be file-hashed, so exclude them from the scope.\n\t\t\tif (artifact.length > 0 && !artifact.endsWith(\"/\")) paths.push(artifact);\n\t\t}\n\t\tconst artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);\n\t\treturn { root, artifactPaths };\n\t} catch {\n\t\treturn { root: cwd, artifactPaths: [] };\n\t}\n}\n"]}
1
+ {"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAGvF;AAeD;;;;;;GAMG;AACH,MAAM,MAAM,wBAAwB,GAAG,UAAU,GAAG,mBAAmB,GAAG,kBAAkB,GAAG,aAAa,CAAC;AAE7G;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA2B;IAC3C,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,qFAAqF;IACrF,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,wBAAwB,CAAC;IAChD,wHAAwH;IACxH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACxC;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAEzG;AAED;;;;;;GAMG;AACH,wBAAgB,kCAAkC,CACjD,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7B,2BAA2B,CAY7B","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { isNormalizedArtifactPath } from \"../guardrails/workspace-fingerprint.ts\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n/** Domain separator so an excluded-set digest can never collide with another digest. */\nconst EXCLUDED_SET_DOMAIN = \"omk:evidence:session-scope-excluded:v1\\0\";\n\nconst scopeCache = new Map<string, { at: number; report: SessionWorkspaceScopeReport }>();\n\n/**\n * Why a session scope is not a complete view of the workspace dirty set.\n *\n * `unavailable` is not `complete`: outside a worktree, or when git cannot be\n * read, nothing was enumerated, so an empty artifact set is an absence of\n * evidence rather than evidence of a clean tree.\n */\nexport type SessionScopeCompleteness = \"complete\" | \"partial_truncated\" | \"partial_excluded\" | \"unavailable\";\n\n/**\n * A session scope plus what it could not bind.\n *\n * The scope drops dirty paths two ways — a hard cap for availability, and the\n * normalized-path filter the receipt parser forces — and both were previously\n * silent, so a receipt built on a partial view read exactly like one that saw\n * the whole workspace. Dropping stays deliberate; hiding it was the defect.\n */\nexport interface SessionWorkspaceScopeReport {\n\treadonly scope: WorkspaceScope;\n\t/** Unique dirty entries git reported, before the cap and the path filter. */\n\treadonly totalDirtyPathCount: number;\n\t/** Entries the scope binds: always `scope.artifactPaths.length`. */\n\treadonly selectedPathCount: number;\n\t/** Unique dirty entries no receipt can bind, such as a name carrying a backslash. */\n\treadonly excludedPathCount: number;\n\t/** True when the cap, not the filter, kept an eligible path out of the scope. */\n\treadonly truncated: boolean;\n\treadonly completeness: SessionScopeCompleteness;\n\t/** Digest of the sorted excluded set, so two different losses are distinguishable. Absent when nothing was excluded. */\n\treadonly excludedPathSetSha256?: string;\n}\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\treturn resolveSessionWorkspaceScopeReport(cwd, options).scope;\n}\n\n/**\n * The scope a receipt would bind, together with the completeness facts that say\n * whether binding it proves anything about the whole workspace.\n *\n * Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too\n * keeps a capped probe from serving a later full request a truncated answer.\n */\nexport function resolveSessionWorkspaceScopeReport(\n\tcwd: string,\n\toptions?: { maxPaths?: number },\n): SessionWorkspaceScopeReport {\n\tconst maxPaths = options?.maxPaths ?? SCOPE_MAX_PATHS;\n\tconst key = `${maxPaths}\\0${cwd}`;\n\tconst cached = scopeCache.get(key);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.report;\n\tconst report = computeSessionWorkspaceScope(cwd, maxPaths);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(key, { at: Date.now(), report });\n\treturn report;\n}\n\n/** Nothing was enumerated, so the empty artifact set proves nothing about the tree. */\nfunction unavailableScope(root: string): SessionWorkspaceScopeReport {\n\treturn {\n\t\tscope: { root, artifactPaths: [] },\n\t\ttotalDirtyPathCount: 0,\n\t\tselectedPathCount: 0,\n\t\texcludedPathCount: 0,\n\t\ttruncated: false,\n\t\tcompleteness: \"unavailable\",\n\t};\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): SessionWorkspaceScopeReport {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return unavailableScope(cwd);\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return unavailableScope(cwd);\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return unavailableScope(root);\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths = new Set<string>();\n\t\tconst excluded = new Set<string>();\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Keep only entries the fingerprint can bind. This drops trailing-slash\n\t\t\t// survivors (untracked nested repositories, whose contents are outside\n\t\t\t// the parent's dirty digest) and names the receipt parser rejects, such\n\t\t\t// as a mangled `\\\\wsl.localhost\\...` directory containing a backslash.\n\t\t\tif (isNormalizedArtifactPath(artifact)) paths.add(artifact);\n\t\t\telse excluded.add(artifact);\n\t\t}\n\t\tconst eligible = [...paths].sort();\n\t\tconst artifactPaths = eligible.slice(0, maxPaths);\n\t\tconst truncated = artifactPaths.length < eligible.length;\n\t\treturn {\n\t\t\tscope: { root, artifactPaths },\n\t\t\ttotalDirtyPathCount: eligible.length + excluded.size,\n\t\t\tselectedPathCount: artifactPaths.length,\n\t\t\texcludedPathCount: excluded.size,\n\t\t\ttruncated,\n\t\t\t// Truncation outranks exclusion: an excluded path is named by the digest\n\t\t\t// below, a capped one is an unbounded unknown.\n\t\t\tcompleteness: truncated ? \"partial_truncated\" : excluded.size > 0 ? \"partial_excluded\" : \"complete\",\n\t\t\t...(excluded.size === 0\n\t\t\t\t? {}\n\t\t\t\t: {\n\t\t\t\t\t\texcludedPathSetSha256: createHash(\"sha256\")\n\t\t\t\t\t\t\t.update(`${EXCLUDED_SET_DOMAIN}${JSON.stringify([...excluded].sort())}`)\n\t\t\t\t\t\t\t.digest(\"hex\"),\n\t\t\t\t\t}),\n\t\t};\n\t} catch {\n\t\treturn unavailableScope(cwd);\n\t}\n}\n"]}
@@ -5,6 +5,8 @@
5
5
  * exact legacy unverified path remains available as a byte-identical rollback.
6
6
  */
7
7
  import { spawnSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import { isNormalizedArtifactPath } from "../guardrails/workspace-fingerprint.js";
8
10
  export function isVerifiedBashEnabled(env) {
9
11
  const source = env ?? process.env;
10
12
  return source.OMK_VERIFIED_BASH !== "0";
@@ -16,6 +18,8 @@ const SCOPE_TTL_MS = 1_000;
16
18
  const SCOPE_CACHE_MAX = 8;
17
19
  const SCOPE_MAX_PATHS = 32;
18
20
  const GIT_TIMEOUT_MS = 1_500;
21
+ /** Domain separator so an excluded-set digest can never collide with another digest. */
22
+ const EXCLUDED_SET_DOMAIN = "omk:evidence:session-scope-excluded:v1\0";
19
23
  const scopeCache = new Map();
20
24
  /**
21
25
  * Workspace scope for session bash receipts. Inside a git worktree the scope
@@ -25,17 +29,40 @@ const scopeCache = new Map();
25
29
  * empty artifact set under `cwd`. Never throws.
26
30
  */
27
31
  export function resolveSessionWorkspaceScope(cwd, options) {
28
- const cached = scopeCache.get(cwd);
32
+ return resolveSessionWorkspaceScopeReport(cwd, options).scope;
33
+ }
34
+ /**
35
+ * The scope a receipt would bind, together with the completeness facts that say
36
+ * whether binding it proves anything about the whole workspace.
37
+ *
38
+ * Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too
39
+ * keeps a capped probe from serving a later full request a truncated answer.
40
+ */
41
+ export function resolveSessionWorkspaceScopeReport(cwd, options) {
42
+ const maxPaths = options?.maxPaths ?? SCOPE_MAX_PATHS;
43
+ const key = `${maxPaths}\0${cwd}`;
44
+ const cached = scopeCache.get(key);
29
45
  if (cached && Date.now() - cached.at < SCOPE_TTL_MS)
30
- return cached.scope;
31
- const scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);
46
+ return cached.report;
47
+ const report = computeSessionWorkspaceScope(cwd, maxPaths);
32
48
  if (scopeCache.size >= SCOPE_CACHE_MAX) {
33
49
  const oldest = scopeCache.keys().next().value;
34
50
  if (oldest !== undefined)
35
51
  scopeCache.delete(oldest);
36
52
  }
37
- scopeCache.set(cwd, { at: Date.now(), scope });
38
- return scope;
53
+ scopeCache.set(key, { at: Date.now(), report });
54
+ return report;
55
+ }
56
+ /** Nothing was enumerated, so the empty artifact set proves nothing about the tree. */
57
+ function unavailableScope(root) {
58
+ return {
59
+ scope: { root, artifactPaths: [] },
60
+ totalDirtyPathCount: 0,
61
+ selectedPathCount: 0,
62
+ excludedPathCount: 0,
63
+ truncated: false,
64
+ completeness: "unavailable",
65
+ };
39
66
  }
40
67
  function computeSessionWorkspaceScope(cwd, maxPaths) {
41
68
  try {
@@ -45,10 +72,10 @@ function computeSessionWorkspaceScope(cwd, maxPaths) {
45
72
  stdio: ["ignore", "pipe", "ignore"],
46
73
  });
47
74
  if (top.status !== 0 || typeof top.stdout !== "string")
48
- return { root: cwd, artifactPaths: [] };
75
+ return unavailableScope(cwd);
49
76
  const root = top.stdout.trim();
50
77
  if (root.length === 0)
51
- return { root: cwd, artifactPaths: [] };
78
+ return unavailableScope(cwd);
52
79
  // File-granular untracked listing: -unormal emits untracked directories as
53
80
  // `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream
54
81
  // and kills every verified bash call in the session.
@@ -58,9 +85,10 @@ function computeSessionWorkspaceScope(cwd, maxPaths) {
58
85
  stdio: ["ignore", "pipe", "ignore"],
59
86
  });
60
87
  if (status.status !== 0 || !status.stdout)
61
- return { root, artifactPaths: [] };
88
+ return unavailableScope(root);
62
89
  const fields = status.stdout.toString("utf8").split("\0");
63
- const paths = [];
90
+ const paths = new Set();
91
+ const excluded = new Set();
64
92
  for (let index = 0; index < fields.length; index++) {
65
93
  const field = fields[index];
66
94
  if (field.length < 4)
@@ -71,17 +99,38 @@ function computeSessionWorkspaceScope(cwd, maxPaths) {
71
99
  if (xy.includes("R") || xy.includes("C"))
72
100
  index++; // rename/copy: consume the source path field too
73
101
  const artifact = field.slice(3);
74
- // Trailing-slash entries survive -uall only for untracked nested
75
- // repositories; their contents are outside the parent's dirty digest
76
- // and cannot be file-hashed, so exclude them from the scope.
77
- if (artifact.length > 0 && !artifact.endsWith("/"))
78
- paths.push(artifact);
102
+ // Keep only entries the fingerprint can bind. This drops trailing-slash
103
+ // survivors (untracked nested repositories, whose contents are outside
104
+ // the parent's dirty digest) and names the receipt parser rejects, such
105
+ // as a mangled `\\wsl.localhost\...` directory containing a backslash.
106
+ if (isNormalizedArtifactPath(artifact))
107
+ paths.add(artifact);
108
+ else
109
+ excluded.add(artifact);
79
110
  }
80
- const artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);
81
- return { root, artifactPaths };
111
+ const eligible = [...paths].sort();
112
+ const artifactPaths = eligible.slice(0, maxPaths);
113
+ const truncated = artifactPaths.length < eligible.length;
114
+ return {
115
+ scope: { root, artifactPaths },
116
+ totalDirtyPathCount: eligible.length + excluded.size,
117
+ selectedPathCount: artifactPaths.length,
118
+ excludedPathCount: excluded.size,
119
+ truncated,
120
+ // Truncation outranks exclusion: an excluded path is named by the digest
121
+ // below, a capped one is an unbounded unknown.
122
+ completeness: truncated ? "partial_truncated" : excluded.size > 0 ? "partial_excluded" : "complete",
123
+ ...(excluded.size === 0
124
+ ? {}
125
+ : {
126
+ excludedPathSetSha256: createHash("sha256")
127
+ .update(`${EXCLUDED_SET_DOMAIN}${JSON.stringify([...excluded].sort())}`)
128
+ .digest("hex"),
129
+ }),
130
+ };
82
131
  }
83
132
  catch {
84
- return { root: cwd, artifactPaths: [] };
133
+ return unavailableScope(cwd);
85
134
  }
86
135
  }
87
136
  //# sourceMappingURL=verified-bash-runtime.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"verified-bash-runtime.js","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n\nconst scopeCache = new Map<string, { at: number; scope: WorkspaceScope }>();\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\tconst cached = scopeCache.get(cwd);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.scope;\n\tconst scope = computeSessionWorkspaceScope(cwd, options?.maxPaths ?? SCOPE_MAX_PATHS);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(cwd, { at: Date.now(), scope });\n\treturn scope;\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): WorkspaceScope {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return { root: cwd, artifactPaths: [] };\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return { root: cwd, artifactPaths: [] };\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return { root, artifactPaths: [] };\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths: string[] = [];\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Trailing-slash entries survive -uall only for untracked nested\n\t\t\t// repositories; their contents are outside the parent's dirty digest\n\t\t\t// and cannot be file-hashed, so exclude them from the scope.\n\t\t\tif (artifact.length > 0 && !artifact.endsWith(\"/\")) paths.push(artifact);\n\t\t}\n\t\tconst artifactPaths = [...new Set(paths)].sort().slice(0, maxPaths);\n\t\treturn { root, artifactPaths };\n\t} catch {\n\t\treturn { root: cwd, artifactPaths: [] };\n\t}\n}\n"]}
1
+ {"version":3,"file":"verified-bash-runtime.js","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { isNormalizedArtifactPath } from \"../guardrails/workspace-fingerprint.ts\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n/** Domain separator so an excluded-set digest can never collide with another digest. */\nconst EXCLUDED_SET_DOMAIN = \"omk:evidence:session-scope-excluded:v1\\0\";\n\nconst scopeCache = new Map<string, { at: number; report: SessionWorkspaceScopeReport }>();\n\n/**\n * Why a session scope is not a complete view of the workspace dirty set.\n *\n * `unavailable` is not `complete`: outside a worktree, or when git cannot be\n * read, nothing was enumerated, so an empty artifact set is an absence of\n * evidence rather than evidence of a clean tree.\n */\nexport type SessionScopeCompleteness = \"complete\" | \"partial_truncated\" | \"partial_excluded\" | \"unavailable\";\n\n/**\n * A session scope plus what it could not bind.\n *\n * The scope drops dirty paths two ways — a hard cap for availability, and the\n * normalized-path filter the receipt parser forces — and both were previously\n * silent, so a receipt built on a partial view read exactly like one that saw\n * the whole workspace. Dropping stays deliberate; hiding it was the defect.\n */\nexport interface SessionWorkspaceScopeReport {\n\treadonly scope: WorkspaceScope;\n\t/** Unique dirty entries git reported, before the cap and the path filter. */\n\treadonly totalDirtyPathCount: number;\n\t/** Entries the scope binds: always `scope.artifactPaths.length`. */\n\treadonly selectedPathCount: number;\n\t/** Unique dirty entries no receipt can bind, such as a name carrying a backslash. */\n\treadonly excludedPathCount: number;\n\t/** True when the cap, not the filter, kept an eligible path out of the scope. */\n\treadonly truncated: boolean;\n\treadonly completeness: SessionScopeCompleteness;\n\t/** Digest of the sorted excluded set, so two different losses are distinguishable. Absent when nothing was excluded. */\n\treadonly excludedPathSetSha256?: string;\n}\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\treturn resolveSessionWorkspaceScopeReport(cwd, options).scope;\n}\n\n/**\n * The scope a receipt would bind, together with the completeness facts that say\n * whether binding it proves anything about the whole workspace.\n *\n * Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too\n * keeps a capped probe from serving a later full request a truncated answer.\n */\nexport function resolveSessionWorkspaceScopeReport(\n\tcwd: string,\n\toptions?: { maxPaths?: number },\n): SessionWorkspaceScopeReport {\n\tconst maxPaths = options?.maxPaths ?? SCOPE_MAX_PATHS;\n\tconst key = `${maxPaths}\\0${cwd}`;\n\tconst cached = scopeCache.get(key);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.report;\n\tconst report = computeSessionWorkspaceScope(cwd, maxPaths);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(key, { at: Date.now(), report });\n\treturn report;\n}\n\n/** Nothing was enumerated, so the empty artifact set proves nothing about the tree. */\nfunction unavailableScope(root: string): SessionWorkspaceScopeReport {\n\treturn {\n\t\tscope: { root, artifactPaths: [] },\n\t\ttotalDirtyPathCount: 0,\n\t\tselectedPathCount: 0,\n\t\texcludedPathCount: 0,\n\t\ttruncated: false,\n\t\tcompleteness: \"unavailable\",\n\t};\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): SessionWorkspaceScopeReport {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return unavailableScope(cwd);\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return unavailableScope(cwd);\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return unavailableScope(root);\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths = new Set<string>();\n\t\tconst excluded = new Set<string>();\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Keep only entries the fingerprint can bind. This drops trailing-slash\n\t\t\t// survivors (untracked nested repositories, whose contents are outside\n\t\t\t// the parent's dirty digest) and names the receipt parser rejects, such\n\t\t\t// as a mangled `\\\\wsl.localhost\\...` directory containing a backslash.\n\t\t\tif (isNormalizedArtifactPath(artifact)) paths.add(artifact);\n\t\t\telse excluded.add(artifact);\n\t\t}\n\t\tconst eligible = [...paths].sort();\n\t\tconst artifactPaths = eligible.slice(0, maxPaths);\n\t\tconst truncated = artifactPaths.length < eligible.length;\n\t\treturn {\n\t\t\tscope: { root, artifactPaths },\n\t\t\ttotalDirtyPathCount: eligible.length + excluded.size,\n\t\t\tselectedPathCount: artifactPaths.length,\n\t\t\texcludedPathCount: excluded.size,\n\t\t\ttruncated,\n\t\t\t// Truncation outranks exclusion: an excluded path is named by the digest\n\t\t\t// below, a capped one is an unbounded unknown.\n\t\t\tcompleteness: truncated ? \"partial_truncated\" : excluded.size > 0 ? \"partial_excluded\" : \"complete\",\n\t\t\t...(excluded.size === 0\n\t\t\t\t? {}\n\t\t\t\t: {\n\t\t\t\t\t\texcludedPathSetSha256: createHash(\"sha256\")\n\t\t\t\t\t\t\t.update(`${EXCLUDED_SET_DOMAIN}${JSON.stringify([...excluded].sort())}`)\n\t\t\t\t\t\t\t.digest(\"hex\"),\n\t\t\t\t\t}),\n\t\t};\n\t} catch {\n\t\treturn unavailableScope(cwd);\n\t}\n}\n"]}
@@ -4,6 +4,8 @@ export interface WorkspaceFingerprintCaptureOptions {
4
4
  /** Tests only: mutate a selected path immediately before its artifact is opened. */
5
5
  readonly faultInjector?: (stage: WorkspaceFingerprintCaptureFaultStage, artifactPath: string) => void;
6
6
  }
7
+ /** Scope producers filter with this so one stray dirty entry cannot poison every receipt. */
8
+ export declare function isNormalizedArtifactPath(path: unknown): path is string;
7
9
  /** Compute the domain-separated digest for an already validated canonical manifest. */
8
10
  export declare function computeWorkspaceManifestSha256(scope: WorkspaceScope, artifacts: readonly ArtifactState[]): Sha256Hex;
9
11
  /** Canonical digest committing sorted changed paths, both diff digests, and every selected artifact state. */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-fingerprint.d.ts","sourceRoot":"","sources":["../../src/guardrails/workspace-fingerprint.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAEX,aAAa,EAGb,iBAAiB,EACjB,SAAS,EACT,oBAAoB,EACpB,cAAc,EACd,MAAM,sBAAsB,CAAC;AAuC9B,MAAM,MAAM,qCAAqC,GAAG,sBAAsB,CAAC;AAE3E,MAAM,WAAW,kCAAkC;IAClD,oFAAoF;IACpF,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,qCAAqC,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;CACtG;AA0KD,uFAAuF;AACvF,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,CAEpH;AAED,8GAA8G;AAC9G,wBAAgB,8BAA8B,CAC7C,YAAY,EAAE,SAAS,MAAM,EAAE,EAC/B,gBAAgB,EAAE,SAAS,EAC3B,kBAAkB,EAAE,SAAS,EAC7B,SAAS,EAAE,SAAS,aAAa,EAAE,GACjC,SAAS,CAYX;AAED,2FAA2F;AAC3F,wBAAgB,iCAAiC,CAChD,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,SAAS,aAAa,EAAE,EACnC,GAAG,EAAE,iBAAiB,GACpB,SAAS,CAkBX;AA6ED,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,oBAAoB,CAU9E;AAsVD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,cAAc,EACrB,OAAO,GAAE,kCAAuC,GAC9C,oBAAoB,CA2CtB","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { createHash, timingSafeEqual } from \"node:crypto\";\nimport type { Stats } from \"node:fs\";\nimport {\n\tcloseSync,\n\tconstants as fsConstants,\n\tfstatSync,\n\tlstatSync,\n\topenSync,\n\treadFileSync,\n\trealpathSync,\n} from \"node:fs\";\nimport { isAbsolute, join, posix, relative, resolve, sep, win32 } from \"node:path\";\nimport type {\n\tArtifactSetWorkspaceFingerprint,\n\tArtifactState,\n\tFileArtifactState,\n\tGitWorkspaceFingerprint,\n\tGitWorkspaceState,\n\tSha256Hex,\n\tWorkspaceFingerprint,\n\tWorkspaceScope,\n} from \"../types/evidence.ts\";\n\nconst SHA256_HEX = /^[0-9a-f]{64}$/;\nconst WORKSPACE_MANIFEST_DOMAIN = \"omk:evidence:workspace-fingerprint:v1\\0\";\nconst GIT_DIRTY_DOMAIN = \"omk:evidence:workspace-fingerprint:git-dirty:v1\\0\";\n/** Full SHA-1 or SHA-256 git object names. */\nconst GIT_COMMIT_HEX = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\n/** Hard bound on bytes accepted from one git child process. */\nconst MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024;\nconst GIT_CAPTURE_TIMEOUT_MS = 30_000;\n/** Inherited variables that could redirect git away from the trusted root; always stripped. */\nconst UNSAFE_GIT_ENV_VARS = [\n\t\"GIT_ALTERNATE_OBJECT_DIRECTORIES\",\n\t\"GIT_CEILING_DIRECTORIES\",\n\t\"GIT_COMMON_DIR\",\n\t\"GIT_DIFF_OPTS\",\n\t\"GIT_DIR\",\n\t\"GIT_EXTERNAL_DIFF\",\n\t\"GIT_INDEX_FILE\",\n\t\"GIT_NAMESPACE\",\n\t\"GIT_OBJECT_DIRECTORY\",\n\t\"GIT_PREFIX\",\n\t\"GIT_WORK_TREE\",\n] as const;\n/** Deterministic scope-limited diff rendering, independent of local prefix/ext-diff config. */\nconst GIT_DIFF_ARGS = [\n\t\"--full-index\",\n\t\"--binary\",\n\t\"--no-color\",\n\t\"--no-ext-diff\",\n\t\"--no-textconv\",\n\t\"--no-renames\",\n\t\"--ignore-submodules=none\",\n\t\"--src-prefix=a/\",\n\t\"--dst-prefix=b/\",\n] as const;\n\ntype FileStat = Stats;\n\nexport type WorkspaceFingerprintCaptureFaultStage = \"before-artifact-open\";\n\nexport interface WorkspaceFingerprintCaptureOptions {\n\t/** Tests only: mutate a selected path immediately before its artifact is opened. */\n\treadonly faultInjector?: (stage: WorkspaceFingerprintCaptureFaultStage, artifactPath: string) => void;\n}\n\ninterface ParentIdentity {\n\treadonly path: string;\n\treadonly stat: FileStat;\n}\n\nfunction errorCode(error: unknown): string | undefined {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error\n\t\t? String((error as { code?: unknown }).code)\n\t\t: undefined;\n}\n\nfunction sha256(value: Uint8Array | string): Sha256Hex {\n\treturn createHash(\"sha256\").update(value).digest(\"hex\") as Sha256Hex;\n}\n\nfunction exactObject(value: unknown, label: string, keys: readonly string[]): Record<string, unknown> {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`${label} must be an object`);\n\t}\n\tconst descriptors = Object.getOwnPropertyDescriptors(value);\n\tconst actual = Object.entries(descriptors)\n\t\t.filter(([, descriptor]) => descriptor.enumerable)\n\t\t.map(([key]) => key)\n\t\t.sort();\n\tconst expected = [...keys].sort();\n\tif (\n\t\tactual.length !== expected.length ||\n\t\tactual.some((key, index) => key !== expected[index]) ||\n\t\tkeys.some((key) => {\n\t\t\tconst descriptor = descriptors[key];\n\t\t\treturn descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor);\n\t\t})\n\t) {\n\t\tthrow new Error(`${label} has an invalid key set`);\n\t}\n\tconst snapshot: Record<string, unknown> = {};\n\tfor (const key of actual) {\n\t\tconst descriptor = descriptors[key];\n\t\tif (descriptor === undefined || !(\"value\" in descriptor)) {\n\t\t\tthrow new Error(`${label} has an invalid key set`);\n\t\t}\n\t\tsnapshot[key] = descriptor.value;\n\t}\n\treturn snapshot;\n}\n\nfunction ownEnumerableDataProperty(record: Record<string, unknown>, label: string, key: string): unknown {\n\tconst descriptor = Object.getOwnPropertyDescriptor(record, key);\n\tif (descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor)) {\n\t\tthrow new Error(`${label} has an invalid key set`);\n\t}\n\treturn descriptor.value;\n}\n\nfunction exactArray(value: unknown, label: string): unknown[] {\n\tif (!Array.isArray(value)) throw new Error(`${label} must be an array`);\n\tconst descriptors = Object.getOwnPropertyDescriptors(value);\n\tconst lengthDescriptor = descriptors.length as PropertyDescriptor | undefined;\n\tif (lengthDescriptor === undefined || !(\"value\" in lengthDescriptor)) {\n\t\tthrow new Error(`${label} has invalid index properties`);\n\t}\n\tconst rawLength = lengthDescriptor.value;\n\tif (!Number.isSafeInteger(rawLength) || (rawLength as number) < 0) {\n\t\tthrow new Error(`${label} has invalid index properties`);\n\t}\n\tconst length = rawLength as number;\n\tconst snapshot: unknown[] = [];\n\tfor (let index = 0; index < length; index++) {\n\t\tconst descriptor = descriptors[String(index)];\n\t\tif (descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor)) {\n\t\t\tthrow new Error(`${label} must use own enumerable data index properties`);\n\t\t}\n\t\tsnapshot.push(descriptor.value);\n\t}\n\tconst enumerableKeys = Object.entries(descriptors)\n\t\t.filter(([, descriptor]) => descriptor.enumerable)\n\t\t.map(([key]) => key);\n\tif (enumerableKeys.length !== length || enumerableKeys.some((key, index) => key !== String(index))) {\n\t\tthrow new Error(`${label} must use own enumerable data index properties`);\n\t}\n\treturn snapshot;\n}\n\nfunction assertNormalizedArtifactPath(path: unknown, label = \"artifact path\"): asserts path is string {\n\tif (\n\t\ttypeof path !== \"string\" ||\n\t\tpath.length === 0 ||\n\t\tpath.includes(\"\\0\") ||\n\t\tpath.includes(\"\\\\\") ||\n\t\tisAbsolute(path) ||\n\t\twin32.isAbsolute(path) ||\n\t\tpath === \".\" ||\n\t\tposix.normalize(path) !== path ||\n\t\tpath.split(\"/\").some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")\n\t) {\n\t\tthrow new Error(`${label} must be a normalized root-relative path`);\n\t}\n}\n\nfunction assertSortedUniquePaths(paths: readonly string[], label: string): void {\n\tfor (let index = 0; index < paths.length; index++) {\n\t\tassertNormalizedArtifactPath(paths[index], `${label}[${index}]`);\n\t\tif (index > 0 && paths[index - 1] >= paths[index]) {\n\t\t\tthrow new Error(`${label} must be sorted and contain no duplicates`);\n\t\t}\n\t}\n}\n\nfunction parseSha256(value: unknown, label: string): Sha256Hex {\n\tif (typeof value !== \"string\" || !SHA256_HEX.test(value)) {\n\t\tthrow new Error(`${label} must be a lowercase SHA-256 hex digest`);\n\t}\n\treturn value as Sha256Hex;\n}\n\nfunction parseScope(value: unknown): WorkspaceScope {\n\tconst scope = exactObject(value, \"workspace scope\", [\"root\", \"artifactPaths\"]);\n\tif (typeof scope.root !== \"string\" || scope.root.length === 0 || !isAbsolute(scope.root)) {\n\t\tthrow new Error(\"workspace scope root must be an absolute path\");\n\t}\n\tconst rawArtifactPaths = exactArray(scope.artifactPaths, \"workspace scope artifactPaths\");\n\tif (!rawArtifactPaths.every((path) => typeof path === \"string\")) {\n\t\tthrow new Error(\"workspace scope artifactPaths must be a string array\");\n\t}\n\tconst artifactPaths = [...rawArtifactPaths] as string[];\n\tassertSortedUniquePaths(artifactPaths, \"workspace scope artifactPaths\");\n\treturn Object.freeze({ root: scope.root, artifactPaths: Object.freeze(artifactPaths) });\n}\n\nfunction parseArtifact(value: unknown, index: number): ArtifactState {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`workspace artifact[${index}] must be an object`);\n\t}\n\tconst candidate = value as Record<string, unknown>;\n\tconst state = ownEnumerableDataProperty(candidate, `workspace artifact[${index}]`, \"state\");\n\tif (state === \"missing\") {\n\t\tconst artifact = exactObject(value, `workspace artifact[${index}]`, [\"path\", \"state\"]);\n\t\tassertNormalizedArtifactPath(artifact.path, `workspace artifact[${index}].path`);\n\t\treturn Object.freeze({ path: artifact.path, state: \"missing\" });\n\t}\n\tif (state === \"file\") {\n\t\tconst artifact = exactObject(value, `workspace artifact[${index}]`, [\"path\", \"state\", \"sha256\", \"size\"]);\n\t\tassertNormalizedArtifactPath(artifact.path, `workspace artifact[${index}].path`);\n\t\tif (!Number.isSafeInteger(artifact.size) || (artifact.size as number) < 0) {\n\t\t\tthrow new Error(`workspace artifact[${index}].size must be a non-negative safe integer`);\n\t\t}\n\t\treturn Object.freeze({\n\t\t\tpath: artifact.path,\n\t\t\tstate: \"file\",\n\t\t\tsha256: parseSha256(artifact.sha256, `workspace artifact[${index}].sha256`),\n\t\t\tsize: artifact.size as number,\n\t\t});\n\t}\n\tthrow new Error(`workspace artifact[${index}].state must be file or missing`);\n}\n\nfunction canonicalManifest(scope: WorkspaceScope, artifacts: readonly ArtifactState[]): string {\n\treturn JSON.stringify({\n\t\tkind: \"artifact-set\",\n\t\tscope: { root: scope.root, artifactPaths: scope.artifactPaths },\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t});\n}\n\n/** Compute the domain-separated digest for an already validated canonical manifest. */\nexport function computeWorkspaceManifestSha256(scope: WorkspaceScope, artifacts: readonly ArtifactState[]): Sha256Hex {\n\treturn sha256(`${WORKSPACE_MANIFEST_DOMAIN}${canonicalManifest(scope, artifacts)}`);\n}\n\n/** Canonical digest committing sorted changed paths, both diff digests, and every selected artifact state. */\nexport function computeGitWorkspaceDirtySha256(\n\tchangedPaths: readonly string[],\n\tstagedDiffSha256: Sha256Hex,\n\tunstagedDiffSha256: Sha256Hex,\n\tartifacts: readonly ArtifactState[],\n): Sha256Hex {\n\tconst canonical = JSON.stringify({\n\t\tchangedPaths,\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t});\n\treturn sha256(`${GIT_DIRTY_DOMAIN}${canonical}`);\n}\n\n/** Compute the domain-separated digest for an already validated canonical git manifest. */\nexport function computeGitWorkspaceManifestSha256(\n\tscope: WorkspaceScope,\n\tartifacts: readonly ArtifactState[],\n\tgit: GitWorkspaceState,\n): Sha256Hex {\n\tconst canonical = JSON.stringify({\n\t\tkind: \"git\",\n\t\tscope: { root: scope.root, artifactPaths: scope.artifactPaths },\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t\tgit: {\n\t\t\theadCommit: git.headCommit,\n\t\t\tchangedPaths: git.changedPaths,\n\t\t\tstagedDiffSha256: git.stagedDiffSha256,\n\t\t\tunstagedDiffSha256: git.unstagedDiffSha256,\n\t\t\tdirtySha256: git.dirtySha256,\n\t\t},\n\t});\n\treturn sha256(`${WORKSPACE_MANIFEST_DOMAIN}${canonical}`);\n}\n\n/** A changed path is inside scope when it equals a selected path or lies under a selected directory. */\nfunction isWithinScope(path: string, artifactPaths: readonly string[]): boolean {\n\treturn artifactPaths.some((artifact) => path === artifact || path.startsWith(`${artifact}/`));\n}\n\nfunction digestsEqual(left: Sha256Hex, right: Sha256Hex): boolean {\n\treturn timingSafeEqual(Buffer.from(left, \"hex\"), Buffer.from(right, \"hex\"));\n}\n\nfunction parseGitFingerprint(value: unknown): GitWorkspaceFingerprint {\n\tconst raw = exactObject(value, \"workspace fingerprint\", [\"kind\", \"scope\", \"artifacts\", \"git\", \"manifestSha256\"]);\n\tconst scope = parseScope(raw.scope);\n\tconst rawArtifacts = exactArray(raw.artifacts, \"workspace fingerprint artifacts\");\n\tconst artifacts = rawArtifacts.map((artifact, index) => parseArtifact(artifact, index));\n\tconst artifactPaths = artifacts.map((artifact) => artifact.path);\n\tassertSortedUniquePaths(artifactPaths, \"workspace artifact paths\");\n\tif (\n\t\tartifactPaths.length !== scope.artifactPaths.length ||\n\t\tartifactPaths.some((path, index) => path !== scope.artifactPaths[index])\n\t) {\n\t\tthrow new Error(\"workspace artifacts must exactly match the selected scope paths\");\n\t}\n\tconst rawGit = exactObject(raw.git, \"workspace git state\", [\n\t\t\"headCommit\",\n\t\t\"changedPaths\",\n\t\t\"stagedDiffSha256\",\n\t\t\"unstagedDiffSha256\",\n\t\t\"dirtySha256\",\n\t]);\n\tconst headCommit = rawGit.headCommit;\n\tif (headCommit !== null && (typeof headCommit !== \"string\" || !GIT_COMMIT_HEX.test(headCommit))) {\n\t\tthrow new Error(\"workspace git headCommit must be null or a full lowercase hex object name\");\n\t}\n\tconst rawChangedPaths = exactArray(rawGit.changedPaths, \"workspace git changedPaths\");\n\tif (!rawChangedPaths.every((path) => typeof path === \"string\")) {\n\t\tthrow new Error(\"workspace git changedPaths must be a string array\");\n\t}\n\tconst changedPaths = [...rawChangedPaths] as string[];\n\tassertSortedUniquePaths(changedPaths, \"workspace git changedPaths\");\n\tfor (const path of changedPaths) {\n\t\tif (!isWithinScope(path, scope.artifactPaths)) {\n\t\t\tthrow new Error(`workspace git changed path escapes the selected scope: ${path}`);\n\t\t}\n\t}\n\tconst stagedDiffSha256 = parseSha256(rawGit.stagedDiffSha256, \"workspace git stagedDiffSha256\");\n\tconst unstagedDiffSha256 = parseSha256(rawGit.unstagedDiffSha256, \"workspace git unstagedDiffSha256\");\n\tconst dirtySha256 = parseSha256(rawGit.dirtySha256, \"workspace git dirtySha256\");\n\tif (\n\t\t!digestsEqual(\n\t\t\tdirtySha256,\n\t\t\tcomputeGitWorkspaceDirtySha256(changedPaths, stagedDiffSha256, unstagedDiffSha256, artifacts),\n\t\t)\n\t) {\n\t\tthrow new Error(\"workspace git dirty digest mismatch\");\n\t}\n\tconst git: GitWorkspaceState = Object.freeze({\n\t\theadCommit,\n\t\tchangedPaths: Object.freeze(changedPaths),\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tdirtySha256,\n\t});\n\tconst manifestSha256 = parseSha256(raw.manifestSha256, \"workspace manifestSha256\");\n\tif (!digestsEqual(manifestSha256, computeGitWorkspaceManifestSha256(scope, artifacts, git))) {\n\t\tthrow new Error(\"workspace manifest digest mismatch\");\n\t}\n\treturn Object.freeze({\n\t\tkind: \"git\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tgit,\n\t\tmanifestSha256,\n\t});\n}\n\n/** Strictly parse and validate a fingerprint of either kind, including its canonical digests. */\nexport function parseWorkspaceFingerprint(value: unknown): WorkspaceFingerprint {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(\"workspace fingerprint must be an object\");\n\t}\n\tconst kind = ownEnumerableDataProperty(value as Record<string, unknown>, \"workspace fingerprint\", \"kind\");\n\tif (kind === \"git\") return parseGitFingerprint(value);\n\tif (kind !== \"artifact-set\") {\n\t\tthrow new Error(\"workspace fingerprint kind must be artifact-set or git\");\n\t}\n\treturn parseArtifactSetFingerprint(value);\n}\n\nfunction parseArtifactSetFingerprint(value: unknown): ArtifactSetWorkspaceFingerprint {\n\tconst raw = exactObject(value, \"workspace fingerprint\", [\"kind\", \"scope\", \"artifacts\", \"manifestSha256\"]);\n\tif (raw.kind !== \"artifact-set\") {\n\t\tthrow new Error(\"workspace fingerprint kind must be artifact-set\");\n\t}\n\tconst scope = parseScope(raw.scope);\n\tconst rawArtifacts = exactArray(raw.artifacts, \"workspace fingerprint artifacts\");\n\tconst artifacts = rawArtifacts.map((artifact, index) => parseArtifact(artifact, index));\n\tconst artifactPaths = artifacts.map((artifact) => artifact.path);\n\tassertSortedUniquePaths(artifactPaths, \"workspace artifact paths\");\n\tif (\n\t\tartifactPaths.length !== scope.artifactPaths.length ||\n\t\tartifactPaths.some((path, index) => path !== scope.artifactPaths[index])\n\t) {\n\t\tthrow new Error(\"workspace artifacts must exactly match the selected scope paths\");\n\t}\n\tconst manifestSha256 = parseSha256(raw.manifestSha256, \"workspace manifestSha256\");\n\tconst expected = computeWorkspaceManifestSha256(scope, artifacts);\n\tif (!digestsEqual(manifestSha256, expected)) {\n\t\tthrow new Error(\"workspace manifest digest mismatch\");\n\t}\n\treturn Object.freeze({\n\t\tkind: \"artifact-set\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tmanifestSha256,\n\t});\n}\n\nfunction statPathWithoutSymlinks(root: string, artifactPath: string): FileStat | undefined {\n\tlet current = root;\n\tconst segments = artifactPath.split(\"/\");\n\tfor (let index = 0; index < segments.length; index++) {\n\t\tcurrent = join(current, segments[index]);\n\t\tlet stat: FileStat;\n\t\ttry {\n\t\t\tstat = lstatSync(current);\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) === \"ENOENT\") return undefined;\n\t\t\tthrow error;\n\t\t}\n\t\tif (stat.isSymbolicLink()) {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tif (index < segments.length - 1 && !stat.isDirectory()) {\n\t\t\tthrow new Error(`workspace artifact parent is not a directory: ${artifactPath}`);\n\t\t}\n\t\tif (index === segments.length - 1) return stat;\n\t}\n\treturn undefined;\n}\n\nfunction captureParentIdentities(root: string, artifactPath: string): readonly ParentIdentity[] {\n\tconst parents: ParentIdentity[] = [];\n\tlet current = root;\n\tfor (const segment of artifactPath.split(\"/\").slice(0, -1)) {\n\t\tcurrent = join(current, segment);\n\t\tconst stat = lstatSync(current);\n\t\tif (stat.isSymbolicLink()) {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tif (!stat.isDirectory()) {\n\t\t\tthrow new Error(`workspace artifact parent is not a directory: ${artifactPath}`);\n\t\t}\n\t\tparents.push({ path: current, stat });\n\t}\n\treturn parents;\n}\n\nfunction hasFileIdentity(stat: FileStat): boolean {\n\treturn stat.dev !== 0 || stat.ino !== 0;\n}\n\nfunction sameFileIdentity(expected: FileStat, actual: FileStat): boolean {\n\tconst expectedHasIdentity = hasFileIdentity(expected);\n\tconst actualHasIdentity = hasFileIdentity(actual);\n\treturn (\n\t\t(!expectedHasIdentity && !actualHasIdentity) ||\n\t\t(expectedHasIdentity && actualHasIdentity && expected.dev === actual.dev && expected.ino === actual.ino)\n\t);\n}\n\nfunction isStrictlyUnder(root: string, path: string): boolean {\n\tconst relativePath = relative(root, path);\n\treturn (\n\t\trelativePath !== \"\" && relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)\n\t);\n}\n\nfunction assertTrustedRoot(root: string, trustedRootStat: FileStat): void {\n\tconst liveRootStat = lstatSync(root);\n\tif (\n\t\tliveRootStat.isSymbolicLink() ||\n\t\t!liveRootStat.isDirectory() ||\n\t\trealpathSync(root) !== root ||\n\t\t!sameFileIdentity(trustedRootStat, liveRootStat)\n\t) {\n\t\tthrow new Error(\"workspace root changed while artifacts were captured\");\n\t}\n}\n\nfunction assertParentIdentities(parents: readonly ParentIdentity[], artifactPath: string): void {\n\tfor (const parent of parents) {\n\t\tconst stat = lstatSync(parent.path);\n\t\tif (stat.isSymbolicLink() || !stat.isDirectory() || !sameFileIdentity(parent.stat, stat)) {\n\t\t\tthrow new Error(`workspace artifact parent changed while it was captured: ${artifactPath}`);\n\t\t}\n\t}\n}\n\nfunction assertLiveArtifactPath(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\ttrustedParents: readonly ParentIdentity[],\n\tartifactPath: string,\n\tfdStat: FileStat,\n): void {\n\tassertTrustedRoot(root, trustedRootStat);\n\tassertParentIdentities(trustedParents, artifactPath);\n\tconst pathStat = statPathWithoutSymlinks(root, artifactPath);\n\tif (pathStat === undefined || !pathStat.isFile() || !sameFileIdentity(fdStat, pathStat)) {\n\t\tthrow new Error(`workspace artifact path was rebound while it was captured: ${artifactPath}`);\n\t}\n\tconst fullPath = join(root, ...artifactPath.split(\"/\"));\n\tconst livePath = realpathSync(fullPath);\n\tif (!isStrictlyUnder(root, livePath)) {\n\t\tthrow new Error(`workspace artifact resolved outside the trusted root: ${artifactPath}`);\n\t}\n\tconst livePathStat = lstatSync(livePath);\n\tif (!livePathStat.isFile() || !sameFileIdentity(fdStat, livePathStat)) {\n\t\tthrow new Error(`workspace artifact path was rebound while it was captured: ${artifactPath}`);\n\t}\n\tassertParentIdentities(trustedParents, artifactPath);\n\tassertTrustedRoot(root, trustedRootStat);\n}\n\nfunction captureFile(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\ttrustedParents: readonly ParentIdentity[],\n\tartifactPath: string,\n): FileArtifactState {\n\tconst fullPath = join(root, ...artifactPath.split(\"/\"));\n\tconst flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);\n\tlet fd: number;\n\ttry {\n\t\tfd = openSync(fullPath, flags);\n\t} catch (error) {\n\t\tif (errorCode(error) === \"ELOOP\") {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tthrow error;\n\t}\n\ttry {\n\t\tconst before = fstatSync(fd);\n\t\tif (!before.isFile()) {\n\t\t\tthrow new Error(`workspace artifact is not a regular file: ${artifactPath}`);\n\t\t}\n\t\tassertLiveArtifactPath(root, trustedRootStat, trustedParents, artifactPath, before);\n\t\tconst bytes = readFileSync(fd);\n\t\tconst after = fstatSync(fd);\n\t\tassertLiveArtifactPath(root, trustedRootStat, trustedParents, artifactPath, after);\n\t\tif (\n\t\t\t!after.isFile() ||\n\t\t\t!sameFileIdentity(before, after) ||\n\t\t\tbefore.size !== after.size ||\n\t\t\tbefore.mtimeMs !== after.mtimeMs ||\n\t\t\tbefore.ctimeMs !== after.ctimeMs ||\n\t\t\tafter.size !== bytes.byteLength\n\t\t) {\n\t\t\tthrow new Error(`workspace artifact changed while it was captured: ${artifactPath}`);\n\t\t}\n\t\treturn Object.freeze({ path: artifactPath, state: \"file\", sha256: sha256(bytes), size: bytes.byteLength });\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n}\n\nfunction captureSelectedArtifact(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\tartifactPath: string,\n\toptions: WorkspaceFingerprintCaptureOptions,\n): ArtifactState {\n\tassertTrustedRoot(root, trustedRootStat);\n\tconst stat = statPathWithoutSymlinks(root, artifactPath);\n\tif (stat === undefined) {\n\t\tassertTrustedRoot(root, trustedRootStat);\n\t\treturn Object.freeze({ path: artifactPath, state: \"missing\" });\n\t}\n\tif (!stat.isFile()) {\n\t\tthrow new Error(`workspace artifact is not a regular file: ${artifactPath}`);\n\t}\n\tconst trustedParents = captureParentIdentities(root, artifactPath);\n\toptions.faultInjector?.(\"before-artifact-open\", artifactPath);\n\treturn captureFile(root, trustedRootStat, trustedParents, artifactPath);\n}\n\n/** True when the trusted root itself hosts a `.git` entry; a symlinked `.git` fails closed. */\nfunction isGitWorkspaceRoot(root: string): boolean {\n\tlet stat: FileStat;\n\ttry {\n\t\tstat = lstatSync(join(root, \".git\"));\n\t} catch (error) {\n\t\tif (errorCode(error) === \"ENOENT\") return false;\n\t\tthrow error;\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(\"workspace root .git must not be a symlink\");\n\t}\n\treturn true;\n}\n\nfunction gitCaptureEnv(): NodeJS.ProcessEnv {\n\tconst env: NodeJS.ProcessEnv = { ...process.env };\n\tfor (const name of UNSAFE_GIT_ENV_VARS) delete env[name];\n\tenv.GIT_OPTIONAL_LOCKS = \"0\";\n\tenv.LC_ALL = \"C\";\n\treturn env;\n}\n\n/** Run one git subcommand with argv boundaries (never a shell) and bounded, time-limited output. */\nfunction runGitCapture(\n\troot: string,\n\targs: readonly string[],\n\tallowedExitCodes: readonly number[] = [0],\n): { status: number; stdout: Buffer } {\n\tconst result = spawnSync(\"git\", [\"-C\", root, ...args], {\n\t\tenv: gitCaptureEnv(),\n\t\tmaxBuffer: MAX_GIT_OUTPUT_BYTES,\n\t\ttimeout: GIT_CAPTURE_TIMEOUT_MS,\n\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\twindowsHide: true,\n\t});\n\tif (result.error !== undefined) {\n\t\tconst code = errorCode(result.error);\n\t\tthrow new Error(`workspace git capture failed to run git: ${code ?? result.error.message}`);\n\t}\n\tif (result.status === null || !allowedExitCodes.includes(result.status)) {\n\t\tthrow new Error(`workspace git capture: git exited with status ${String(result.status)}`);\n\t}\n\treturn { status: result.status, stdout: result.stdout };\n}\n\ninterface GitScopeStatus {\n\treadonly changedPaths: readonly string[];\n}\n\nfunction parseGitStatusOutput(stdout: Buffer, artifactPaths: readonly string[]): GitScopeStatus {\n\tconst changed = new Set<string>();\n\tfor (const entry of stdout.toString(\"utf8\").split(\"\\0\")) {\n\t\tif (entry.length === 0) continue;\n\t\tif (entry.length < 4 || entry[2] !== \" \") {\n\t\t\tthrow new Error(\"workspace git capture: unexpected status entry shape\");\n\t\t}\n\t\tconst path = entry.slice(3);\n\t\tassertNormalizedArtifactPath(path, \"workspace git status path\");\n\t\tif (!isWithinScope(path, artifactPaths)) {\n\t\t\tthrow new Error(`workspace git status path escapes the selected scope: ${path}`);\n\t\t}\n\t\tchanged.add(path);\n\t}\n\treturn { changedPaths: [...changed].sort() };\n}\n\n/**\n * Capture HEAD plus a scope-limited canonical dirty digest for a git work-tree root.\n * All git invocations are argv-only, env-sanitized, bounded, and pathspec-limited with\n * literal magic. Every exact selected path is also captured with symlink-free,\n * root-contained file reads, independent of ignore rules and index flags.\n */\nfunction captureGitWorkspaceFingerprint(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\tscope: WorkspaceScope,\n\toptions: WorkspaceFingerprintCaptureOptions,\n): GitWorkspaceFingerprint {\n\tconst toplevel = runGitCapture(root, [\"rev-parse\", \"--show-toplevel\"]).stdout.toString(\"utf8\").replace(/\\n$/, \"\");\n\tif (toplevel.length === 0 || realpathSync(toplevel) !== root) {\n\t\tthrow new Error(\"workspace root must be the git work-tree top level\");\n\t}\n\tconst head = runGitCapture(root, [\"rev-parse\", \"--verify\", \"--quiet\", \"HEAD^{commit}\"], [0, 1]);\n\tlet headCommit: string | null = null;\n\tif (head.status === 0) {\n\t\tconst hex = head.stdout.toString(\"utf8\").trim();\n\t\tif (!GIT_COMMIT_HEX.test(hex)) {\n\t\t\tthrow new Error(\"workspace git capture: HEAD is not a full hex object name\");\n\t\t}\n\t\theadCommit = hex;\n\t}\n\tconst pathspecs = scope.artifactPaths.map((path) => `:(literal)${path}`);\n\tlet status: GitScopeStatus = { changedPaths: [] };\n\tlet stagedDiff: Buffer = Buffer.alloc(0);\n\tlet unstagedDiff: Buffer = Buffer.alloc(0);\n\tif (pathspecs.length > 0) {\n\t\tstatus = parseGitStatusOutput(\n\t\t\trunGitCapture(root, [\n\t\t\t\t\"-c\",\n\t\t\t\t\"core.fsmonitor=false\",\n\t\t\t\t\"status\",\n\t\t\t\t\"--porcelain=v1\",\n\t\t\t\t\"-z\",\n\t\t\t\t\"--untracked-files=all\",\n\t\t\t\t\"--no-renames\",\n\t\t\t\t\"--ignore-submodules=none\",\n\t\t\t\t\"--\",\n\t\t\t\t...pathspecs,\n\t\t\t]).stdout,\n\t\t\tscope.artifactPaths,\n\t\t);\n\t\tstagedDiff = runGitCapture(root, [\"diff\", \"--cached\", ...GIT_DIFF_ARGS, \"--\", ...pathspecs]).stdout;\n\t\tunstagedDiff = runGitCapture(root, [\"diff\", ...GIT_DIFF_ARGS, \"--\", ...pathspecs]).stdout;\n\t}\n\tconst artifacts = scope.artifactPaths.map((artifactPath) =>\n\t\tcaptureSelectedArtifact(root, trustedRootStat, artifactPath, options),\n\t);\n\tassertTrustedRoot(root, trustedRootStat);\n\tconst changedPaths = Object.freeze([...status.changedPaths]);\n\tconst stagedDiffSha256 = sha256(stagedDiff);\n\tconst unstagedDiffSha256 = sha256(unstagedDiff);\n\tconst git: GitWorkspaceState = Object.freeze({\n\t\theadCommit,\n\t\tchangedPaths,\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tdirtySha256: computeGitWorkspaceDirtySha256(changedPaths, stagedDiffSha256, unstagedDiffSha256, artifacts),\n\t});\n\tconst parsed = parseWorkspaceFingerprint({\n\t\tkind: \"git\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tgit,\n\t\tmanifestSha256: computeGitWorkspaceManifestSha256(scope, artifacts, git),\n\t});\n\tif (parsed.kind !== \"git\") {\n\t\tthrow new Error(\"workspace git capture produced a non-git fingerprint\");\n\t}\n\treturn parsed;\n}\n\n/**\n * Hash the selected workspace scope. When the trusted root is itself a git work-tree\n * top level, capture HEAD plus a scope-limited canonical dirty digest; otherwise hash\n * the caller-selected artifact set directly and perform no git commands.\n * Phase A assumes a trusted, quiescent workspace: pathname identity checks reject\n * observed rebinds, but do not prove an immutable snapshot against a same-UID adversary.\n */\nexport function captureWorkspaceFingerprint(\n\tscope: WorkspaceScope,\n\toptions: WorkspaceFingerprintCaptureOptions = {},\n): WorkspaceFingerprint {\n\tconst rawScope = exactObject(scope, \"workspace scope\", [\"root\", \"artifactPaths\"]);\n\tconst rawRoot = rawScope.root;\n\tif (typeof rawRoot !== \"string\" || rawRoot.length === 0) {\n\t\tthrow new Error(\"workspace scope root must be a non-empty string\");\n\t}\n\tconst rawArtifactPaths = exactArray(rawScope.artifactPaths, \"workspace scope artifactPaths\");\n\n\tconst seen = new Set<string>();\n\tfor (let index = 0; index < rawArtifactPaths.length; index++) {\n\t\tconst artifactPath = rawArtifactPaths[index];\n\t\tassertNormalizedArtifactPath(artifactPath, `workspace scope artifactPaths[${index}]`);\n\t\tif (seen.has(artifactPath)) {\n\t\t\tthrow new Error(`duplicate workspace artifact path: ${artifactPath}`);\n\t\t}\n\t\tseen.add(artifactPath);\n\t}\n\n\tconst resolvedRoot = resolve(rawRoot);\n\tconst rootStat = lstatSync(resolvedRoot);\n\tif (rootStat.isSymbolicLink()) {\n\t\tthrow new Error(\"workspace root must not be a symlink\");\n\t}\n\tif (!rootStat.isDirectory()) {\n\t\tthrow new Error(\"workspace root must be a directory\");\n\t}\n\tconst root = realpathSync(resolvedRoot);\n\tconst trustedRootStat = lstatSync(root);\n\tconst artifactPaths = (rawArtifactPaths as string[]).sort();\n\tconst canonicalScope: WorkspaceScope = Object.freeze({ root, artifactPaths: Object.freeze(artifactPaths) });\n\tif (isGitWorkspaceRoot(root)) {\n\t\treturn captureGitWorkspaceFingerprint(root, trustedRootStat, canonicalScope, options);\n\t}\n\tconst artifacts = artifactPaths.map((artifactPath) =>\n\t\tcaptureSelectedArtifact(root, trustedRootStat, artifactPath, options),\n\t);\n\tconst fingerprint = {\n\t\tkind: \"artifact-set\",\n\t\tscope: canonicalScope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tmanifestSha256: computeWorkspaceManifestSha256(canonicalScope, artifacts),\n\t};\n\treturn parseWorkspaceFingerprint(fingerprint);\n}\n"]}
1
+ {"version":3,"file":"workspace-fingerprint.d.ts","sourceRoot":"","sources":["../../src/guardrails/workspace-fingerprint.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAEX,aAAa,EAGb,iBAAiB,EACjB,SAAS,EACT,oBAAoB,EACpB,cAAc,EACd,MAAM,sBAAsB,CAAC;AAuC9B,MAAM,MAAM,qCAAqC,GAAG,sBAAsB,CAAC;AAE3E,MAAM,WAAW,kCAAkC;IAClD,oFAAoF;IACpF,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,qCAAqC,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;CACtG;AAqFD,6FAA6F;AAC7F,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,CAWtE;AA2ED,uFAAuF;AACvF,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,CAEpH;AAED,8GAA8G;AAC9G,wBAAgB,8BAA8B,CAC7C,YAAY,EAAE,SAAS,MAAM,EAAE,EAC/B,gBAAgB,EAAE,SAAS,EAC3B,kBAAkB,EAAE,SAAS,EAC7B,SAAS,EAAE,SAAS,aAAa,EAAE,GACjC,SAAS,CAYX;AAED,2FAA2F;AAC3F,wBAAgB,iCAAiC,CAChD,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,SAAS,aAAa,EAAE,EACnC,GAAG,EAAE,iBAAiB,GACpB,SAAS,CAkBX;AA6ED,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,oBAAoB,CAU9E;AAsVD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,cAAc,EACrB,OAAO,GAAE,kCAAuC,GAC9C,oBAAoB,CA2CtB","sourcesContent":["import { spawnSync } from \"node:child_process\";\nimport { createHash, timingSafeEqual } from \"node:crypto\";\nimport type { Stats } from \"node:fs\";\nimport {\n\tcloseSync,\n\tconstants as fsConstants,\n\tfstatSync,\n\tlstatSync,\n\topenSync,\n\treadFileSync,\n\trealpathSync,\n} from \"node:fs\";\nimport { isAbsolute, join, posix, relative, resolve, sep, win32 } from \"node:path\";\nimport type {\n\tArtifactSetWorkspaceFingerprint,\n\tArtifactState,\n\tFileArtifactState,\n\tGitWorkspaceFingerprint,\n\tGitWorkspaceState,\n\tSha256Hex,\n\tWorkspaceFingerprint,\n\tWorkspaceScope,\n} from \"../types/evidence.ts\";\n\nconst SHA256_HEX = /^[0-9a-f]{64}$/;\nconst WORKSPACE_MANIFEST_DOMAIN = \"omk:evidence:workspace-fingerprint:v1\\0\";\nconst GIT_DIRTY_DOMAIN = \"omk:evidence:workspace-fingerprint:git-dirty:v1\\0\";\n/** Full SHA-1 or SHA-256 git object names. */\nconst GIT_COMMIT_HEX = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\n/** Hard bound on bytes accepted from one git child process. */\nconst MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024;\nconst GIT_CAPTURE_TIMEOUT_MS = 30_000;\n/** Inherited variables that could redirect git away from the trusted root; always stripped. */\nconst UNSAFE_GIT_ENV_VARS = [\n\t\"GIT_ALTERNATE_OBJECT_DIRECTORIES\",\n\t\"GIT_CEILING_DIRECTORIES\",\n\t\"GIT_COMMON_DIR\",\n\t\"GIT_DIFF_OPTS\",\n\t\"GIT_DIR\",\n\t\"GIT_EXTERNAL_DIFF\",\n\t\"GIT_INDEX_FILE\",\n\t\"GIT_NAMESPACE\",\n\t\"GIT_OBJECT_DIRECTORY\",\n\t\"GIT_PREFIX\",\n\t\"GIT_WORK_TREE\",\n] as const;\n/** Deterministic scope-limited diff rendering, independent of local prefix/ext-diff config. */\nconst GIT_DIFF_ARGS = [\n\t\"--full-index\",\n\t\"--binary\",\n\t\"--no-color\",\n\t\"--no-ext-diff\",\n\t\"--no-textconv\",\n\t\"--no-renames\",\n\t\"--ignore-submodules=none\",\n\t\"--src-prefix=a/\",\n\t\"--dst-prefix=b/\",\n] as const;\n\ntype FileStat = Stats;\n\nexport type WorkspaceFingerprintCaptureFaultStage = \"before-artifact-open\";\n\nexport interface WorkspaceFingerprintCaptureOptions {\n\t/** Tests only: mutate a selected path immediately before its artifact is opened. */\n\treadonly faultInjector?: (stage: WorkspaceFingerprintCaptureFaultStage, artifactPath: string) => void;\n}\n\ninterface ParentIdentity {\n\treadonly path: string;\n\treadonly stat: FileStat;\n}\n\nfunction errorCode(error: unknown): string | undefined {\n\treturn typeof error === \"object\" && error !== null && \"code\" in error\n\t\t? String((error as { code?: unknown }).code)\n\t\t: undefined;\n}\n\nfunction sha256(value: Uint8Array | string): Sha256Hex {\n\treturn createHash(\"sha256\").update(value).digest(\"hex\") as Sha256Hex;\n}\n\nfunction exactObject(value: unknown, label: string, keys: readonly string[]): Record<string, unknown> {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`${label} must be an object`);\n\t}\n\tconst descriptors = Object.getOwnPropertyDescriptors(value);\n\tconst actual = Object.entries(descriptors)\n\t\t.filter(([, descriptor]) => descriptor.enumerable)\n\t\t.map(([key]) => key)\n\t\t.sort();\n\tconst expected = [...keys].sort();\n\tif (\n\t\tactual.length !== expected.length ||\n\t\tactual.some((key, index) => key !== expected[index]) ||\n\t\tkeys.some((key) => {\n\t\t\tconst descriptor = descriptors[key];\n\t\t\treturn descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor);\n\t\t})\n\t) {\n\t\tthrow new Error(`${label} has an invalid key set`);\n\t}\n\tconst snapshot: Record<string, unknown> = {};\n\tfor (const key of actual) {\n\t\tconst descriptor = descriptors[key];\n\t\tif (descriptor === undefined || !(\"value\" in descriptor)) {\n\t\t\tthrow new Error(`${label} has an invalid key set`);\n\t\t}\n\t\tsnapshot[key] = descriptor.value;\n\t}\n\treturn snapshot;\n}\n\nfunction ownEnumerableDataProperty(record: Record<string, unknown>, label: string, key: string): unknown {\n\tconst descriptor = Object.getOwnPropertyDescriptor(record, key);\n\tif (descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor)) {\n\t\tthrow new Error(`${label} has an invalid key set`);\n\t}\n\treturn descriptor.value;\n}\n\nfunction exactArray(value: unknown, label: string): unknown[] {\n\tif (!Array.isArray(value)) throw new Error(`${label} must be an array`);\n\tconst descriptors = Object.getOwnPropertyDescriptors(value);\n\tconst lengthDescriptor = descriptors.length as PropertyDescriptor | undefined;\n\tif (lengthDescriptor === undefined || !(\"value\" in lengthDescriptor)) {\n\t\tthrow new Error(`${label} has invalid index properties`);\n\t}\n\tconst rawLength = lengthDescriptor.value;\n\tif (!Number.isSafeInteger(rawLength) || (rawLength as number) < 0) {\n\t\tthrow new Error(`${label} has invalid index properties`);\n\t}\n\tconst length = rawLength as number;\n\tconst snapshot: unknown[] = [];\n\tfor (let index = 0; index < length; index++) {\n\t\tconst descriptor = descriptors[String(index)];\n\t\tif (descriptor === undefined || descriptor.enumerable !== true || !(\"value\" in descriptor)) {\n\t\t\tthrow new Error(`${label} must use own enumerable data index properties`);\n\t\t}\n\t\tsnapshot.push(descriptor.value);\n\t}\n\tconst enumerableKeys = Object.entries(descriptors)\n\t\t.filter(([, descriptor]) => descriptor.enumerable)\n\t\t.map(([key]) => key);\n\tif (enumerableKeys.length !== length || enumerableKeys.some((key, index) => key !== String(index))) {\n\t\tthrow new Error(`${label} must use own enumerable data index properties`);\n\t}\n\treturn snapshot;\n}\n\n/** Scope producers filter with this so one stray dirty entry cannot poison every receipt. */\nexport function isNormalizedArtifactPath(path: unknown): path is string {\n\treturn (\n\t\ttypeof path === \"string\" &&\n\t\tpath.length > 0 &&\n\t\t!path.includes(\"\\0\") &&\n\t\t!path.includes(\"\\\\\") &&\n\t\t!isAbsolute(path) &&\n\t\t!win32.isAbsolute(path) &&\n\t\tposix.normalize(path) === path &&\n\t\t!path.split(\"/\").some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")\n\t);\n}\n\nfunction assertNormalizedArtifactPath(path: unknown, label = \"artifact path\"): asserts path is string {\n\tif (!isNormalizedArtifactPath(path)) throw new Error(`${label} must be a normalized root-relative path`);\n}\n\nfunction assertSortedUniquePaths(paths: readonly string[], label: string): void {\n\tfor (let index = 0; index < paths.length; index++) {\n\t\tassertNormalizedArtifactPath(paths[index], `${label}[${index}]`);\n\t\tif (index > 0 && paths[index - 1] >= paths[index]) {\n\t\t\tthrow new Error(`${label} must be sorted and contain no duplicates`);\n\t\t}\n\t}\n}\n\nfunction parseSha256(value: unknown, label: string): Sha256Hex {\n\tif (typeof value !== \"string\" || !SHA256_HEX.test(value)) {\n\t\tthrow new Error(`${label} must be a lowercase SHA-256 hex digest`);\n\t}\n\treturn value as Sha256Hex;\n}\n\nfunction parseScope(value: unknown): WorkspaceScope {\n\tconst scope = exactObject(value, \"workspace scope\", [\"root\", \"artifactPaths\"]);\n\tif (typeof scope.root !== \"string\" || scope.root.length === 0 || !isAbsolute(scope.root)) {\n\t\tthrow new Error(\"workspace scope root must be an absolute path\");\n\t}\n\tconst rawArtifactPaths = exactArray(scope.artifactPaths, \"workspace scope artifactPaths\");\n\tif (!rawArtifactPaths.every((path) => typeof path === \"string\")) {\n\t\tthrow new Error(\"workspace scope artifactPaths must be a string array\");\n\t}\n\tconst artifactPaths = [...rawArtifactPaths] as string[];\n\tassertSortedUniquePaths(artifactPaths, \"workspace scope artifactPaths\");\n\treturn Object.freeze({ root: scope.root, artifactPaths: Object.freeze(artifactPaths) });\n}\n\nfunction parseArtifact(value: unknown, index: number): ArtifactState {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`workspace artifact[${index}] must be an object`);\n\t}\n\tconst candidate = value as Record<string, unknown>;\n\tconst state = ownEnumerableDataProperty(candidate, `workspace artifact[${index}]`, \"state\");\n\tif (state === \"missing\") {\n\t\tconst artifact = exactObject(value, `workspace artifact[${index}]`, [\"path\", \"state\"]);\n\t\tassertNormalizedArtifactPath(artifact.path, `workspace artifact[${index}].path`);\n\t\treturn Object.freeze({ path: artifact.path, state: \"missing\" });\n\t}\n\tif (state === \"file\") {\n\t\tconst artifact = exactObject(value, `workspace artifact[${index}]`, [\"path\", \"state\", \"sha256\", \"size\"]);\n\t\tassertNormalizedArtifactPath(artifact.path, `workspace artifact[${index}].path`);\n\t\tif (!Number.isSafeInteger(artifact.size) || (artifact.size as number) < 0) {\n\t\t\tthrow new Error(`workspace artifact[${index}].size must be a non-negative safe integer`);\n\t\t}\n\t\treturn Object.freeze({\n\t\t\tpath: artifact.path,\n\t\t\tstate: \"file\",\n\t\t\tsha256: parseSha256(artifact.sha256, `workspace artifact[${index}].sha256`),\n\t\t\tsize: artifact.size as number,\n\t\t});\n\t}\n\tthrow new Error(`workspace artifact[${index}].state must be file or missing`);\n}\n\nfunction canonicalManifest(scope: WorkspaceScope, artifacts: readonly ArtifactState[]): string {\n\treturn JSON.stringify({\n\t\tkind: \"artifact-set\",\n\t\tscope: { root: scope.root, artifactPaths: scope.artifactPaths },\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t});\n}\n\n/** Compute the domain-separated digest for an already validated canonical manifest. */\nexport function computeWorkspaceManifestSha256(scope: WorkspaceScope, artifacts: readonly ArtifactState[]): Sha256Hex {\n\treturn sha256(`${WORKSPACE_MANIFEST_DOMAIN}${canonicalManifest(scope, artifacts)}`);\n}\n\n/** Canonical digest committing sorted changed paths, both diff digests, and every selected artifact state. */\nexport function computeGitWorkspaceDirtySha256(\n\tchangedPaths: readonly string[],\n\tstagedDiffSha256: Sha256Hex,\n\tunstagedDiffSha256: Sha256Hex,\n\tartifacts: readonly ArtifactState[],\n): Sha256Hex {\n\tconst canonical = JSON.stringify({\n\t\tchangedPaths,\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t});\n\treturn sha256(`${GIT_DIRTY_DOMAIN}${canonical}`);\n}\n\n/** Compute the domain-separated digest for an already validated canonical git manifest. */\nexport function computeGitWorkspaceManifestSha256(\n\tscope: WorkspaceScope,\n\tartifacts: readonly ArtifactState[],\n\tgit: GitWorkspaceState,\n): Sha256Hex {\n\tconst canonical = JSON.stringify({\n\t\tkind: \"git\",\n\t\tscope: { root: scope.root, artifactPaths: scope.artifactPaths },\n\t\tartifacts: artifacts.map((artifact) =>\n\t\t\tartifact.state === \"file\"\n\t\t\t\t? { path: artifact.path, state: artifact.state, sha256: artifact.sha256, size: artifact.size }\n\t\t\t\t: { path: artifact.path, state: artifact.state },\n\t\t),\n\t\tgit: {\n\t\t\theadCommit: git.headCommit,\n\t\t\tchangedPaths: git.changedPaths,\n\t\t\tstagedDiffSha256: git.stagedDiffSha256,\n\t\t\tunstagedDiffSha256: git.unstagedDiffSha256,\n\t\t\tdirtySha256: git.dirtySha256,\n\t\t},\n\t});\n\treturn sha256(`${WORKSPACE_MANIFEST_DOMAIN}${canonical}`);\n}\n\n/** A changed path is inside scope when it equals a selected path or lies under a selected directory. */\nfunction isWithinScope(path: string, artifactPaths: readonly string[]): boolean {\n\treturn artifactPaths.some((artifact) => path === artifact || path.startsWith(`${artifact}/`));\n}\n\nfunction digestsEqual(left: Sha256Hex, right: Sha256Hex): boolean {\n\treturn timingSafeEqual(Buffer.from(left, \"hex\"), Buffer.from(right, \"hex\"));\n}\n\nfunction parseGitFingerprint(value: unknown): GitWorkspaceFingerprint {\n\tconst raw = exactObject(value, \"workspace fingerprint\", [\"kind\", \"scope\", \"artifacts\", \"git\", \"manifestSha256\"]);\n\tconst scope = parseScope(raw.scope);\n\tconst rawArtifacts = exactArray(raw.artifacts, \"workspace fingerprint artifacts\");\n\tconst artifacts = rawArtifacts.map((artifact, index) => parseArtifact(artifact, index));\n\tconst artifactPaths = artifacts.map((artifact) => artifact.path);\n\tassertSortedUniquePaths(artifactPaths, \"workspace artifact paths\");\n\tif (\n\t\tartifactPaths.length !== scope.artifactPaths.length ||\n\t\tartifactPaths.some((path, index) => path !== scope.artifactPaths[index])\n\t) {\n\t\tthrow new Error(\"workspace artifacts must exactly match the selected scope paths\");\n\t}\n\tconst rawGit = exactObject(raw.git, \"workspace git state\", [\n\t\t\"headCommit\",\n\t\t\"changedPaths\",\n\t\t\"stagedDiffSha256\",\n\t\t\"unstagedDiffSha256\",\n\t\t\"dirtySha256\",\n\t]);\n\tconst headCommit = rawGit.headCommit;\n\tif (headCommit !== null && (typeof headCommit !== \"string\" || !GIT_COMMIT_HEX.test(headCommit))) {\n\t\tthrow new Error(\"workspace git headCommit must be null or a full lowercase hex object name\");\n\t}\n\tconst rawChangedPaths = exactArray(rawGit.changedPaths, \"workspace git changedPaths\");\n\tif (!rawChangedPaths.every((path) => typeof path === \"string\")) {\n\t\tthrow new Error(\"workspace git changedPaths must be a string array\");\n\t}\n\tconst changedPaths = [...rawChangedPaths] as string[];\n\tassertSortedUniquePaths(changedPaths, \"workspace git changedPaths\");\n\tfor (const path of changedPaths) {\n\t\tif (!isWithinScope(path, scope.artifactPaths)) {\n\t\t\tthrow new Error(`workspace git changed path escapes the selected scope: ${path}`);\n\t\t}\n\t}\n\tconst stagedDiffSha256 = parseSha256(rawGit.stagedDiffSha256, \"workspace git stagedDiffSha256\");\n\tconst unstagedDiffSha256 = parseSha256(rawGit.unstagedDiffSha256, \"workspace git unstagedDiffSha256\");\n\tconst dirtySha256 = parseSha256(rawGit.dirtySha256, \"workspace git dirtySha256\");\n\tif (\n\t\t!digestsEqual(\n\t\t\tdirtySha256,\n\t\t\tcomputeGitWorkspaceDirtySha256(changedPaths, stagedDiffSha256, unstagedDiffSha256, artifacts),\n\t\t)\n\t) {\n\t\tthrow new Error(\"workspace git dirty digest mismatch\");\n\t}\n\tconst git: GitWorkspaceState = Object.freeze({\n\t\theadCommit,\n\t\tchangedPaths: Object.freeze(changedPaths),\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tdirtySha256,\n\t});\n\tconst manifestSha256 = parseSha256(raw.manifestSha256, \"workspace manifestSha256\");\n\tif (!digestsEqual(manifestSha256, computeGitWorkspaceManifestSha256(scope, artifacts, git))) {\n\t\tthrow new Error(\"workspace manifest digest mismatch\");\n\t}\n\treturn Object.freeze({\n\t\tkind: \"git\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tgit,\n\t\tmanifestSha256,\n\t});\n}\n\n/** Strictly parse and validate a fingerprint of either kind, including its canonical digests. */\nexport function parseWorkspaceFingerprint(value: unknown): WorkspaceFingerprint {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(\"workspace fingerprint must be an object\");\n\t}\n\tconst kind = ownEnumerableDataProperty(value as Record<string, unknown>, \"workspace fingerprint\", \"kind\");\n\tif (kind === \"git\") return parseGitFingerprint(value);\n\tif (kind !== \"artifact-set\") {\n\t\tthrow new Error(\"workspace fingerprint kind must be artifact-set or git\");\n\t}\n\treturn parseArtifactSetFingerprint(value);\n}\n\nfunction parseArtifactSetFingerprint(value: unknown): ArtifactSetWorkspaceFingerprint {\n\tconst raw = exactObject(value, \"workspace fingerprint\", [\"kind\", \"scope\", \"artifacts\", \"manifestSha256\"]);\n\tif (raw.kind !== \"artifact-set\") {\n\t\tthrow new Error(\"workspace fingerprint kind must be artifact-set\");\n\t}\n\tconst scope = parseScope(raw.scope);\n\tconst rawArtifacts = exactArray(raw.artifacts, \"workspace fingerprint artifacts\");\n\tconst artifacts = rawArtifacts.map((artifact, index) => parseArtifact(artifact, index));\n\tconst artifactPaths = artifacts.map((artifact) => artifact.path);\n\tassertSortedUniquePaths(artifactPaths, \"workspace artifact paths\");\n\tif (\n\t\tartifactPaths.length !== scope.artifactPaths.length ||\n\t\tartifactPaths.some((path, index) => path !== scope.artifactPaths[index])\n\t) {\n\t\tthrow new Error(\"workspace artifacts must exactly match the selected scope paths\");\n\t}\n\tconst manifestSha256 = parseSha256(raw.manifestSha256, \"workspace manifestSha256\");\n\tconst expected = computeWorkspaceManifestSha256(scope, artifacts);\n\tif (!digestsEqual(manifestSha256, expected)) {\n\t\tthrow new Error(\"workspace manifest digest mismatch\");\n\t}\n\treturn Object.freeze({\n\t\tkind: \"artifact-set\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tmanifestSha256,\n\t});\n}\n\nfunction statPathWithoutSymlinks(root: string, artifactPath: string): FileStat | undefined {\n\tlet current = root;\n\tconst segments = artifactPath.split(\"/\");\n\tfor (let index = 0; index < segments.length; index++) {\n\t\tcurrent = join(current, segments[index]);\n\t\tlet stat: FileStat;\n\t\ttry {\n\t\t\tstat = lstatSync(current);\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) === \"ENOENT\") return undefined;\n\t\t\tthrow error;\n\t\t}\n\t\tif (stat.isSymbolicLink()) {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tif (index < segments.length - 1 && !stat.isDirectory()) {\n\t\t\tthrow new Error(`workspace artifact parent is not a directory: ${artifactPath}`);\n\t\t}\n\t\tif (index === segments.length - 1) return stat;\n\t}\n\treturn undefined;\n}\n\nfunction captureParentIdentities(root: string, artifactPath: string): readonly ParentIdentity[] {\n\tconst parents: ParentIdentity[] = [];\n\tlet current = root;\n\tfor (const segment of artifactPath.split(\"/\").slice(0, -1)) {\n\t\tcurrent = join(current, segment);\n\t\tconst stat = lstatSync(current);\n\t\tif (stat.isSymbolicLink()) {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tif (!stat.isDirectory()) {\n\t\t\tthrow new Error(`workspace artifact parent is not a directory: ${artifactPath}`);\n\t\t}\n\t\tparents.push({ path: current, stat });\n\t}\n\treturn parents;\n}\n\nfunction hasFileIdentity(stat: FileStat): boolean {\n\treturn stat.dev !== 0 || stat.ino !== 0;\n}\n\nfunction sameFileIdentity(expected: FileStat, actual: FileStat): boolean {\n\tconst expectedHasIdentity = hasFileIdentity(expected);\n\tconst actualHasIdentity = hasFileIdentity(actual);\n\treturn (\n\t\t(!expectedHasIdentity && !actualHasIdentity) ||\n\t\t(expectedHasIdentity && actualHasIdentity && expected.dev === actual.dev && expected.ino === actual.ino)\n\t);\n}\n\nfunction isStrictlyUnder(root: string, path: string): boolean {\n\tconst relativePath = relative(root, path);\n\treturn (\n\t\trelativePath !== \"\" && relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)\n\t);\n}\n\nfunction assertTrustedRoot(root: string, trustedRootStat: FileStat): void {\n\tconst liveRootStat = lstatSync(root);\n\tif (\n\t\tliveRootStat.isSymbolicLink() ||\n\t\t!liveRootStat.isDirectory() ||\n\t\trealpathSync(root) !== root ||\n\t\t!sameFileIdentity(trustedRootStat, liveRootStat)\n\t) {\n\t\tthrow new Error(\"workspace root changed while artifacts were captured\");\n\t}\n}\n\nfunction assertParentIdentities(parents: readonly ParentIdentity[], artifactPath: string): void {\n\tfor (const parent of parents) {\n\t\tconst stat = lstatSync(parent.path);\n\t\tif (stat.isSymbolicLink() || !stat.isDirectory() || !sameFileIdentity(parent.stat, stat)) {\n\t\t\tthrow new Error(`workspace artifact parent changed while it was captured: ${artifactPath}`);\n\t\t}\n\t}\n}\n\nfunction assertLiveArtifactPath(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\ttrustedParents: readonly ParentIdentity[],\n\tartifactPath: string,\n\tfdStat: FileStat,\n): void {\n\tassertTrustedRoot(root, trustedRootStat);\n\tassertParentIdentities(trustedParents, artifactPath);\n\tconst pathStat = statPathWithoutSymlinks(root, artifactPath);\n\tif (pathStat === undefined || !pathStat.isFile() || !sameFileIdentity(fdStat, pathStat)) {\n\t\tthrow new Error(`workspace artifact path was rebound while it was captured: ${artifactPath}`);\n\t}\n\tconst fullPath = join(root, ...artifactPath.split(\"/\"));\n\tconst livePath = realpathSync(fullPath);\n\tif (!isStrictlyUnder(root, livePath)) {\n\t\tthrow new Error(`workspace artifact resolved outside the trusted root: ${artifactPath}`);\n\t}\n\tconst livePathStat = lstatSync(livePath);\n\tif (!livePathStat.isFile() || !sameFileIdentity(fdStat, livePathStat)) {\n\t\tthrow new Error(`workspace artifact path was rebound while it was captured: ${artifactPath}`);\n\t}\n\tassertParentIdentities(trustedParents, artifactPath);\n\tassertTrustedRoot(root, trustedRootStat);\n}\n\nfunction captureFile(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\ttrustedParents: readonly ParentIdentity[],\n\tartifactPath: string,\n): FileArtifactState {\n\tconst fullPath = join(root, ...artifactPath.split(\"/\"));\n\tconst flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);\n\tlet fd: number;\n\ttry {\n\t\tfd = openSync(fullPath, flags);\n\t} catch (error) {\n\t\tif (errorCode(error) === \"ELOOP\") {\n\t\t\tthrow new Error(`workspace artifact path contains a symlink: ${artifactPath}`);\n\t\t}\n\t\tthrow error;\n\t}\n\ttry {\n\t\tconst before = fstatSync(fd);\n\t\tif (!before.isFile()) {\n\t\t\tthrow new Error(`workspace artifact is not a regular file: ${artifactPath}`);\n\t\t}\n\t\tassertLiveArtifactPath(root, trustedRootStat, trustedParents, artifactPath, before);\n\t\tconst bytes = readFileSync(fd);\n\t\tconst after = fstatSync(fd);\n\t\tassertLiveArtifactPath(root, trustedRootStat, trustedParents, artifactPath, after);\n\t\tif (\n\t\t\t!after.isFile() ||\n\t\t\t!sameFileIdentity(before, after) ||\n\t\t\tbefore.size !== after.size ||\n\t\t\tbefore.mtimeMs !== after.mtimeMs ||\n\t\t\tbefore.ctimeMs !== after.ctimeMs ||\n\t\t\tafter.size !== bytes.byteLength\n\t\t) {\n\t\t\tthrow new Error(`workspace artifact changed while it was captured: ${artifactPath}`);\n\t\t}\n\t\treturn Object.freeze({ path: artifactPath, state: \"file\", sha256: sha256(bytes), size: bytes.byteLength });\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n}\n\nfunction captureSelectedArtifact(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\tartifactPath: string,\n\toptions: WorkspaceFingerprintCaptureOptions,\n): ArtifactState {\n\tassertTrustedRoot(root, trustedRootStat);\n\tconst stat = statPathWithoutSymlinks(root, artifactPath);\n\tif (stat === undefined) {\n\t\tassertTrustedRoot(root, trustedRootStat);\n\t\treturn Object.freeze({ path: artifactPath, state: \"missing\" });\n\t}\n\tif (!stat.isFile()) {\n\t\tthrow new Error(`workspace artifact is not a regular file: ${artifactPath}`);\n\t}\n\tconst trustedParents = captureParentIdentities(root, artifactPath);\n\toptions.faultInjector?.(\"before-artifact-open\", artifactPath);\n\treturn captureFile(root, trustedRootStat, trustedParents, artifactPath);\n}\n\n/** True when the trusted root itself hosts a `.git` entry; a symlinked `.git` fails closed. */\nfunction isGitWorkspaceRoot(root: string): boolean {\n\tlet stat: FileStat;\n\ttry {\n\t\tstat = lstatSync(join(root, \".git\"));\n\t} catch (error) {\n\t\tif (errorCode(error) === \"ENOENT\") return false;\n\t\tthrow error;\n\t}\n\tif (stat.isSymbolicLink()) {\n\t\tthrow new Error(\"workspace root .git must not be a symlink\");\n\t}\n\treturn true;\n}\n\nfunction gitCaptureEnv(): NodeJS.ProcessEnv {\n\tconst env: NodeJS.ProcessEnv = { ...process.env };\n\tfor (const name of UNSAFE_GIT_ENV_VARS) delete env[name];\n\tenv.GIT_OPTIONAL_LOCKS = \"0\";\n\tenv.LC_ALL = \"C\";\n\treturn env;\n}\n\n/** Run one git subcommand with argv boundaries (never a shell) and bounded, time-limited output. */\nfunction runGitCapture(\n\troot: string,\n\targs: readonly string[],\n\tallowedExitCodes: readonly number[] = [0],\n): { status: number; stdout: Buffer } {\n\tconst result = spawnSync(\"git\", [\"-C\", root, ...args], {\n\t\tenv: gitCaptureEnv(),\n\t\tmaxBuffer: MAX_GIT_OUTPUT_BYTES,\n\t\ttimeout: GIT_CAPTURE_TIMEOUT_MS,\n\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\twindowsHide: true,\n\t});\n\tif (result.error !== undefined) {\n\t\tconst code = errorCode(result.error);\n\t\tthrow new Error(`workspace git capture failed to run git: ${code ?? result.error.message}`);\n\t}\n\tif (result.status === null || !allowedExitCodes.includes(result.status)) {\n\t\tthrow new Error(`workspace git capture: git exited with status ${String(result.status)}`);\n\t}\n\treturn { status: result.status, stdout: result.stdout };\n}\n\ninterface GitScopeStatus {\n\treadonly changedPaths: readonly string[];\n}\n\nfunction parseGitStatusOutput(stdout: Buffer, artifactPaths: readonly string[]): GitScopeStatus {\n\tconst changed = new Set<string>();\n\tfor (const entry of stdout.toString(\"utf8\").split(\"\\0\")) {\n\t\tif (entry.length === 0) continue;\n\t\tif (entry.length < 4 || entry[2] !== \" \") {\n\t\t\tthrow new Error(\"workspace git capture: unexpected status entry shape\");\n\t\t}\n\t\tconst path = entry.slice(3);\n\t\tassertNormalizedArtifactPath(path, \"workspace git status path\");\n\t\tif (!isWithinScope(path, artifactPaths)) {\n\t\t\tthrow new Error(`workspace git status path escapes the selected scope: ${path}`);\n\t\t}\n\t\tchanged.add(path);\n\t}\n\treturn { changedPaths: [...changed].sort() };\n}\n\n/**\n * Capture HEAD plus a scope-limited canonical dirty digest for a git work-tree root.\n * All git invocations are argv-only, env-sanitized, bounded, and pathspec-limited with\n * literal magic. Every exact selected path is also captured with symlink-free,\n * root-contained file reads, independent of ignore rules and index flags.\n */\nfunction captureGitWorkspaceFingerprint(\n\troot: string,\n\ttrustedRootStat: FileStat,\n\tscope: WorkspaceScope,\n\toptions: WorkspaceFingerprintCaptureOptions,\n): GitWorkspaceFingerprint {\n\tconst toplevel = runGitCapture(root, [\"rev-parse\", \"--show-toplevel\"]).stdout.toString(\"utf8\").replace(/\\n$/, \"\");\n\tif (toplevel.length === 0 || realpathSync(toplevel) !== root) {\n\t\tthrow new Error(\"workspace root must be the git work-tree top level\");\n\t}\n\tconst head = runGitCapture(root, [\"rev-parse\", \"--verify\", \"--quiet\", \"HEAD^{commit}\"], [0, 1]);\n\tlet headCommit: string | null = null;\n\tif (head.status === 0) {\n\t\tconst hex = head.stdout.toString(\"utf8\").trim();\n\t\tif (!GIT_COMMIT_HEX.test(hex)) {\n\t\t\tthrow new Error(\"workspace git capture: HEAD is not a full hex object name\");\n\t\t}\n\t\theadCommit = hex;\n\t}\n\tconst pathspecs = scope.artifactPaths.map((path) => `:(literal)${path}`);\n\tlet status: GitScopeStatus = { changedPaths: [] };\n\tlet stagedDiff: Buffer = Buffer.alloc(0);\n\tlet unstagedDiff: Buffer = Buffer.alloc(0);\n\tif (pathspecs.length > 0) {\n\t\tstatus = parseGitStatusOutput(\n\t\t\trunGitCapture(root, [\n\t\t\t\t\"-c\",\n\t\t\t\t\"core.fsmonitor=false\",\n\t\t\t\t\"status\",\n\t\t\t\t\"--porcelain=v1\",\n\t\t\t\t\"-z\",\n\t\t\t\t\"--untracked-files=all\",\n\t\t\t\t\"--no-renames\",\n\t\t\t\t\"--ignore-submodules=none\",\n\t\t\t\t\"--\",\n\t\t\t\t...pathspecs,\n\t\t\t]).stdout,\n\t\t\tscope.artifactPaths,\n\t\t);\n\t\tstagedDiff = runGitCapture(root, [\"diff\", \"--cached\", ...GIT_DIFF_ARGS, \"--\", ...pathspecs]).stdout;\n\t\tunstagedDiff = runGitCapture(root, [\"diff\", ...GIT_DIFF_ARGS, \"--\", ...pathspecs]).stdout;\n\t}\n\tconst artifacts = scope.artifactPaths.map((artifactPath) =>\n\t\tcaptureSelectedArtifact(root, trustedRootStat, artifactPath, options),\n\t);\n\tassertTrustedRoot(root, trustedRootStat);\n\tconst changedPaths = Object.freeze([...status.changedPaths]);\n\tconst stagedDiffSha256 = sha256(stagedDiff);\n\tconst unstagedDiffSha256 = sha256(unstagedDiff);\n\tconst git: GitWorkspaceState = Object.freeze({\n\t\theadCommit,\n\t\tchangedPaths,\n\t\tstagedDiffSha256,\n\t\tunstagedDiffSha256,\n\t\tdirtySha256: computeGitWorkspaceDirtySha256(changedPaths, stagedDiffSha256, unstagedDiffSha256, artifacts),\n\t});\n\tconst parsed = parseWorkspaceFingerprint({\n\t\tkind: \"git\",\n\t\tscope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tgit,\n\t\tmanifestSha256: computeGitWorkspaceManifestSha256(scope, artifacts, git),\n\t});\n\tif (parsed.kind !== \"git\") {\n\t\tthrow new Error(\"workspace git capture produced a non-git fingerprint\");\n\t}\n\treturn parsed;\n}\n\n/**\n * Hash the selected workspace scope. When the trusted root is itself a git work-tree\n * top level, capture HEAD plus a scope-limited canonical dirty digest; otherwise hash\n * the caller-selected artifact set directly and perform no git commands.\n * Phase A assumes a trusted, quiescent workspace: pathname identity checks reject\n * observed rebinds, but do not prove an immutable snapshot against a same-UID adversary.\n */\nexport function captureWorkspaceFingerprint(\n\tscope: WorkspaceScope,\n\toptions: WorkspaceFingerprintCaptureOptions = {},\n): WorkspaceFingerprint {\n\tconst rawScope = exactObject(scope, \"workspace scope\", [\"root\", \"artifactPaths\"]);\n\tconst rawRoot = rawScope.root;\n\tif (typeof rawRoot !== \"string\" || rawRoot.length === 0) {\n\t\tthrow new Error(\"workspace scope root must be a non-empty string\");\n\t}\n\tconst rawArtifactPaths = exactArray(rawScope.artifactPaths, \"workspace scope artifactPaths\");\n\n\tconst seen = new Set<string>();\n\tfor (let index = 0; index < rawArtifactPaths.length; index++) {\n\t\tconst artifactPath = rawArtifactPaths[index];\n\t\tassertNormalizedArtifactPath(artifactPath, `workspace scope artifactPaths[${index}]`);\n\t\tif (seen.has(artifactPath)) {\n\t\t\tthrow new Error(`duplicate workspace artifact path: ${artifactPath}`);\n\t\t}\n\t\tseen.add(artifactPath);\n\t}\n\n\tconst resolvedRoot = resolve(rawRoot);\n\tconst rootStat = lstatSync(resolvedRoot);\n\tif (rootStat.isSymbolicLink()) {\n\t\tthrow new Error(\"workspace root must not be a symlink\");\n\t}\n\tif (!rootStat.isDirectory()) {\n\t\tthrow new Error(\"workspace root must be a directory\");\n\t}\n\tconst root = realpathSync(resolvedRoot);\n\tconst trustedRootStat = lstatSync(root);\n\tconst artifactPaths = (rawArtifactPaths as string[]).sort();\n\tconst canonicalScope: WorkspaceScope = Object.freeze({ root, artifactPaths: Object.freeze(artifactPaths) });\n\tif (isGitWorkspaceRoot(root)) {\n\t\treturn captureGitWorkspaceFingerprint(root, trustedRootStat, canonicalScope, options);\n\t}\n\tconst artifacts = artifactPaths.map((artifactPath) =>\n\t\tcaptureSelectedArtifact(root, trustedRootStat, artifactPath, options),\n\t);\n\tconst fingerprint = {\n\t\tkind: \"artifact-set\",\n\t\tscope: canonicalScope,\n\t\tartifacts: Object.freeze(artifacts),\n\t\tmanifestSha256: computeWorkspaceManifestSha256(canonicalScope, artifacts),\n\t};\n\treturn parseWorkspaceFingerprint(fingerprint);\n}\n"]}
@@ -108,18 +108,20 @@ function exactArray(value, label) {
108
108
  }
109
109
  return snapshot;
110
110
  }
111
+ /** Scope producers filter with this so one stray dirty entry cannot poison every receipt. */
112
+ export function isNormalizedArtifactPath(path) {
113
+ return (typeof path === "string" &&
114
+ path.length > 0 &&
115
+ !path.includes("\0") &&
116
+ !path.includes("\\") &&
117
+ !isAbsolute(path) &&
118
+ !win32.isAbsolute(path) &&
119
+ posix.normalize(path) === path &&
120
+ !path.split("/").some((segment) => segment === "" || segment === "." || segment === ".."));
121
+ }
111
122
  function assertNormalizedArtifactPath(path, label = "artifact path") {
112
- if (typeof path !== "string" ||
113
- path.length === 0 ||
114
- path.includes("\0") ||
115
- path.includes("\\") ||
116
- isAbsolute(path) ||
117
- win32.isAbsolute(path) ||
118
- path === "." ||
119
- posix.normalize(path) !== path ||
120
- path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
123
+ if (!isNormalizedArtifactPath(path))
121
124
  throw new Error(`${label} must be a normalized root-relative path`);
122
- }
123
125
  }
124
126
  function assertSortedUniquePaths(paths, label) {
125
127
  for (let index = 0; index < paths.length; index++) {