audit-tools 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/audit/cli/laneSubmissions.d.ts +2 -2
  2. package/dist/audit/cli/laneSubmissions.js +2 -2
  3. package/dist/audit/cli/nextStepHelpers.d.ts +0 -36
  4. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  5. package/dist/audit/cli/nextStepHelpers.js +0 -76
  6. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  7. package/dist/remediate/phases/triage.d.ts.map +1 -1
  8. package/dist/remediate/phases/triage.js +14 -2
  9. package/dist/remediate/phases/triage.js.map +1 -1
  10. package/dist/remediate/steps/contractPipeline.d.ts +9 -0
  11. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  12. package/dist/remediate/steps/contractPipeline.js +144 -36
  13. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  14. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  15. package/dist/remediate/steps/dispatch/hostHandoff.js +6 -62
  16. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  17. package/dist/remediate/steps/types.d.ts +0 -2
  18. package/dist/remediate/steps/types.d.ts.map +1 -1
  19. package/dist/remediate/steps/types.js +0 -2
  20. package/dist/remediate/steps/types.js.map +1 -1
  21. package/dist/remediate/validation/artifacts.d.ts +0 -2
  22. package/dist/remediate/validation/artifacts.d.ts.map +1 -1
  23. package/dist/remediate/validation/artifacts.js +1 -133
  24. package/dist/remediate/validation/artifacts.js.map +1 -1
  25. package/dist/shared/index.d.ts +1 -0
  26. package/dist/shared/index.d.ts.map +1 -1
  27. package/dist/shared/index.js +5 -0
  28. package/dist/shared/index.js.map +1 -1
  29. package/dist/shared/tooling/commandShape.d.ts +21 -0
  30. package/dist/shared/tooling/commandShape.d.ts.map +1 -0
  31. package/dist/shared/tooling/commandShape.js +120 -0
  32. package/dist/shared/tooling/commandShape.js.map +1 -0
  33. package/package.json +1 -1
@@ -22,7 +22,7 @@
22
22
  import { existsSync } from "node:fs";
23
23
  import { mkdir, readFile, rename, rm } from "node:fs/promises";
24
24
  import { isAbsolute, join, resolve } from "node:path";
25
- import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, isRecord, withFsRetry, projectApprovedFindings, captureStepBoundaryFriction, climbOutOfAuditTools, estimateTokensFromBytes, normalizeRepoPath, repoRelativePath, } from "audit-tools/shared";
25
+ import { writeJsonFile, readOptionalJsonFile, formatValidationIssues, hashContent, isRecord, withFsRetry, projectApprovedFindings, captureStepBoundaryFriction, climbOutOfAuditTools, partitionCommandsByDeclaredShape, estimateTokensFromBytes, normalizeRepoPath, repoRelativePath, } from "audit-tools/shared";
26
26
  import { createStepEmissionScaffold, } from "../../shared/steps/stepEmissionScaffold.js";
27
27
  import { counterexampleFingerprint } from "../contractPipeline/counterexampleFingerprint.js";
28
28
  import { CP_ARTIFACT_NAMES, contractArtifactExists, contractArtifactFilePath, contractInputFilePath, contractPipelineDir, detectStaleArtifacts, envelopePayload, envelopeSemanticHash, isEnvelope, pathASeedFilePath, payloadSemanticHash, readContractArtifact, stampToolCreatedAt, writeContractArtifact, writeDerivedContractArtifact, } from "../contractPipeline/artifactStore.js";
@@ -930,11 +930,106 @@ async function readDecomposedModules(artifactsDir) {
930
930
  return result;
931
931
  }
932
932
  /**
933
- * Single source for "which files may this DAG node write". Declared scope wins
934
- * (`output_files`, else `files_likely_touched`); a node that declared neither
935
- * inherits the `file_scope` of the module(s) its obligations belong to, resolved
936
- * by longest-`OBL-<slug>-` prefix so a short slug never mis-claims a longer
937
- * module's files.
933
+ * Characters that disqualify a finalized-contract entry from being read as a
934
+ * repo-relative write target: any whitespace (prose), `:` (the `artifact:<name>`
935
+ * ordering token and the Windows drive form), and the glob/redirect set no
936
+ * legal declared path carries.
937
+ */
938
+ const NON_WRITE_TARGET_CHARS = /[\s:*?"<>|]/u;
939
+ /**
940
+ * A finalized module contract's `outputs` / `side_effects` are FREE PROSE that
941
+ * may name a file ("src/foo.ts") or may describe an effect ("writes the run
942
+ * ledger under .audit-tools") or carry an ordering token
943
+ * ("artifact:validated-roster" — see ARTIFACT_TOKEN_PATTERN in phaseCut.ts).
944
+ * Only the first kind is a write target, so this is a deliberately CONSERVATIVE
945
+ * parse: an entry qualifies only when it reads unambiguously as a repo-relative
946
+ * path, and everything else is silently dropped — prose stays prose. A false
947
+ * positive here would widen a worker's write scope on the strength of a
948
+ * sentence, which is strictly worse than the manual widening this replaces.
949
+ *
950
+ * Returns the forward-slashed path, or null when the entry is not one.
951
+ */
952
+ function contractDeclaredWriteTarget(entry) {
953
+ if (typeof entry !== "string")
954
+ return null;
955
+ const trimmed = entry.trim();
956
+ if (trimmed.length === 0)
957
+ return null;
958
+ if (NON_WRITE_TARGET_CHARS.test(trimmed))
959
+ return null;
960
+ // Absolute (POSIX or Windows-UNC) forms are not repo-relative.
961
+ if (trimmed.startsWith("/") || trimmed.startsWith("\\"))
962
+ return null;
963
+ const normalized = trimmed.replace(/\\/gu, "/");
964
+ if (normalized.split("/").includes(".."))
965
+ return null;
966
+ // A bare word ("session") is an interface name, not a path. Require either a
967
+ // path separator or a file extension.
968
+ if (!normalized.includes("/") && !/\.[a-z0-9]{1,6}$/iu.test(normalized))
969
+ return null;
970
+ return normalized;
971
+ }
972
+ /**
973
+ * The path-parseable write targets each finalized module contract declares,
974
+ * keyed by `moduleSlug(name)` — the SAME identity the obligation ids encode, so
975
+ * this map joins to the decomposition's modules without a second name space.
976
+ *
977
+ * Degrades to an empty map when the artifact is absent or malformed: this
978
+ * resolver runs on the VALIDATOR's refusal path, so a bad contracts file must
979
+ * cost the widening, never wedge every subsequent next-step with a throw.
980
+ */
981
+ async function readModuleContractWriteTargets(artifactsDir) {
982
+ let finalized;
983
+ try {
984
+ finalized = envelopePayload(await readContractArtifact(artifactsDir, "finalized_module_contracts"));
985
+ }
986
+ catch {
987
+ return new Map();
988
+ }
989
+ const entries = isRecord(finalized) && Array.isArray(finalized.module_contracts)
990
+ ? finalized.module_contracts
991
+ : [];
992
+ const bySlug = new Map();
993
+ for (const entry of entries) {
994
+ if (!isRecord(entry) || typeof entry.name !== "string")
995
+ continue;
996
+ const slug = moduleSlug(entry.name);
997
+ if (slug.length === 0)
998
+ continue;
999
+ const targets = bySlug.get(slug) ?? [];
1000
+ const declared = [
1001
+ ...(Array.isArray(entry.outputs) ? entry.outputs : []),
1002
+ ...(Array.isArray(entry.side_effects) ? entry.side_effects : []),
1003
+ ];
1004
+ for (const raw of declared) {
1005
+ const target = contractDeclaredWriteTarget(raw);
1006
+ if (target !== null && !targets.includes(target))
1007
+ targets.push(target);
1008
+ }
1009
+ bySlug.set(slug, targets);
1010
+ }
1011
+ return bySlug;
1012
+ }
1013
+ /**
1014
+ * Single source for "which files may this DAG node write". The scope is the
1015
+ * UNION of two declarations, never one overriding the other:
1016
+ *
1017
+ * - the node's own declared files (`output_files`, else `files_likely_touched`);
1018
+ * - the path-parseable write targets (`outputs` + `side_effects`) declared by
1019
+ * the finalized contract of the module(s) the node's obligations belong to,
1020
+ * resolved by longest-`OBL-<slug>-` prefix so a short slug never mis-claims a
1021
+ * longer module's targets.
1022
+ *
1023
+ * A node that declared NO files of its own additionally inherits the
1024
+ * `file_scope` of those same modules — that inheritance is the scope-less
1025
+ * FALLBACK only, and is deliberately not unioned into a node that did declare.
1026
+ *
1027
+ * ⚠ The declared-files-win EARLY RETURN this used to perform is deliberately
1028
+ * superseded (owner decision, nightly ledger 2026-08-20). The module contract is
1029
+ * where a module's write targets are declared, and they never reached the node
1030
+ * scope — so an implementer was handed an obligation whose declared target file
1031
+ * was missing from `allowed_files`, and a human widened it by hand (four
1032
+ * recoveries in one wave). Union, not precedence.
938
1033
  *
939
1034
  * ⚠ Shared by the PROMOTER (which derives the scope) and the VALIDATOR (which
940
1035
  * refuses when it resolves to nothing) on purpose. Two copies of this resolution
@@ -952,25 +1047,43 @@ async function readDecomposedModules(artifactsDir) {
952
1047
  */
953
1048
  async function buildNodeWriteScopeResolver(artifactsDir) {
954
1049
  const decomposedModules = await readDecomposedModules(artifactsDir);
1050
+ const contractTargetsBySlug = await readModuleContractWriteTargets(artifactsDir);
955
1051
  const moduleScopesBySlug = decomposedModules
956
- .map((m) => ({ slug: moduleSlug(m.name), files: m.file_scope }))
1052
+ .map((m) => ({
1053
+ slug: moduleSlug(m.name),
1054
+ files: m.file_scope,
1055
+ targets: contractTargetsBySlug.get(moduleSlug(m.name)) ?? [],
1056
+ }))
957
1057
  .sort((a, b) => b.slug.length - a.slug.length);
958
1058
  const resolve = (node) => {
959
1059
  const declared = [...new Set(node.output_files ?? node.files_likely_touched ?? [])];
960
- if (declared.length > 0)
961
- return declared;
962
1060
  const obligationIds = [
963
1061
  ...(node.satisfies_obligations ?? []),
964
1062
  ...(node.verification_obligation_ids ?? []),
965
1063
  ];
966
1064
  const inherited = new Set();
1065
+ const ownedTargets = new Set();
967
1066
  for (const id of obligationIds) {
968
1067
  const owner = moduleScopesBySlug.find((m) => id.startsWith(`OBL-${m.slug}-`));
969
- if (owner)
1068
+ if (!owner)
1069
+ continue;
1070
+ // file_scope inheritance is the scope-less fallback ONLY; the contract's
1071
+ // declared targets are unioned in either way.
1072
+ if (declared.length === 0)
970
1073
  for (const f of owner.files)
971
1074
  inherited.add(f);
1075
+ for (const t of owner.targets)
1076
+ ownedTargets.add(t);
972
1077
  }
973
- return [...inherited];
1078
+ // Content-derived order: the node's own declarations first, in the order
1079
+ // they were declared, then the added targets path-sorted — so the resolved
1080
+ // scope (which reaches the plan's content hash through affected_files)
1081
+ // never churns on module ordering.
1082
+ const base = declared.length > 0 ? declared : [...inherited];
1083
+ const added = [...ownedTargets]
1084
+ .filter((t) => !base.includes(t))
1085
+ .sort((left, right) => compareCodeUnits(left, right));
1086
+ return [...base, ...added];
974
1087
  };
975
1088
  return { resolve, availableSlugs: moduleScopesBySlug.map((m) => m.slug) };
976
1089
  }
@@ -2696,8 +2809,6 @@ function deriveObligationLensAndSeverity(kinds) {
2696
2809
  // separator-inconsistent path becomes one canonical repo-relative form, a path
2697
2810
  // that escapes the repository is refused outright, and a command carrying shell
2698
2811
  // chaining or substitution is refused rather than handed to a shell.
2699
- /** Shell metacharacters a declared package-script / test invocation never needs. */
2700
- const SHELL_METACHARACTERS = /[&|;<>`$\n\r\0]/;
2701
2812
  /**
2702
2813
  * The tracked-path corpus for write-scope checking, or null when the tree
2703
2814
  * cannot be read. Null degrades to "shape-only normalization" exactly as the
@@ -2788,24 +2899,13 @@ export async function evaluatePromotedPlanWriteScope(artifactsDir, root) {
2788
2899
  return violations.length > 0 ? { violations } : null;
2789
2900
  }
2790
2901
  export function normalizeBlockTargetedCommands(commands, blockId) {
2791
- const normalized = [];
2792
- const refusals = [];
2793
- for (const raw of commands) {
2794
- const command = typeof raw === "string" ? raw.trim() : "";
2795
- if (command.length === 0) {
2796
- refusals.push(`Block "${blockId}" declares an empty targeted_commands entry.`);
2797
- continue;
2798
- }
2799
- if (SHELL_METACHARACTERS.test(command)) {
2800
- refusals.push(`Block "${blockId}" declares the targeted_commands entry ${JSON.stringify(raw)}, ` +
2801
- `which carries shell chaining, substitution or redirection. A targeted command is ` +
2802
- `executed verbatim through a shell, so it must be one invocation — split it into ` +
2803
- `separate entries.`);
2804
- continue;
2805
- }
2806
- normalized.push(command);
2807
- }
2808
- return { targeted_commands: normalized, refusals };
2902
+ const partitioned = partitionCommandsByDeclaredShape(commands, (kind, raw) => kind === "empty"
2903
+ ? `Block "${blockId}" declares an empty targeted_commands entry.`
2904
+ : `Block "${blockId}" declares the targeted_commands entry ${JSON.stringify(raw)}, ` +
2905
+ `which carries shell chaining, substitution or redirection. A targeted command is ` +
2906
+ `executed verbatim through a shell, so it must be one invocation — split it into ` +
2907
+ `separate entries.`);
2908
+ return { targeted_commands: partitioned.commands, refusals: partitioned.refusals };
2809
2909
  }
2810
2910
  /**
2811
2911
  * Collect every write-scope and command refusal the promotion WOULD hit, before
@@ -2882,9 +2982,10 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
2882
2982
  // the module decomposition instead of trusting the host to have filled it: each
2883
2983
  // node's obligations are `OBL-<moduleSlug>-…`, and every module declares its
2884
2984
  // `file_scope`, so a node that declared no files inherits the file_scope of the
2885
- // module(s) its obligations belong to. (Declared files still win when present.)
2886
- // Single-sourced with the DAG validator, which refuses a node this resolves to
2887
- // nothing for see buildNodeWriteScopeResolver.
2985
+ // module(s) its obligations belong to. A node that DID declare files still gains
2986
+ // those modules' finalized-contract write targets (P38) the scope is a UNION,
2987
+ // not a precedence. Single-sourced with the DAG validator, which refuses a node
2988
+ // this resolves to nothing for — see buildNodeWriteScopeResolver.
2888
2989
  const { resolve: deriveNodeFiles } = await buildNodeWriteScopeResolver(artifactsDir);
2889
2990
  const nodes = (Array.isArray(dag?.nodes) ? [...dag.nodes] : []).sort((left, right) => String(left.id).localeCompare(String(right.id)));
2890
2991
  // Path-A promotion is an identity-preserving projection. DAG node ids describe
@@ -2951,8 +3052,9 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
2951
3052
  confidence: "high",
2952
3053
  lens,
2953
3054
  summary: node.description ?? node.title ?? "",
2954
- // output_files (declared write scope) takes priority over files_likely_touched;
2955
- // when the node declared neither, inherit the module file_scope (deriveNodeFiles)
3055
+ // output_files (declared write scope) takes priority over files_likely_touched,
3056
+ // unioned with the owning module contract's declared write targets; when the
3057
+ // node declared neither, it inherits the module file_scope (deriveNodeFiles)
2956
3058
  // so the finding is never scope-less. Map each path to the { path } shape that
2957
3059
  // Finding.affected_files expects.
2958
3060
  affected_files: deriveNodeFiles(node).map((p) => ({ path: p })),
@@ -3039,6 +3141,12 @@ export async function promoteImplementationDagToExtractedPlan(artifactsDir, root
3039
3141
  // time promotion runs there is nothing left to refuse. The throw below is a
3040
3142
  // BACKSTOP for a caller that skipped that gate — never the operator-facing
3041
3143
  // path.
3144
+ //
3145
+ // "Nothing left to refuse" is now TRUE BY CONSTRUCTION, not by hope: the
3146
+ // command half asks the ONE shared `commandLeavesDeclaredShape` predicate
3147
+ // that the host-handoff consumer asks, so a command this gate admits cannot
3148
+ // be refused downstream (and vice versa). It used to be a claim about two
3149
+ // independent implementations that disagreed in both directions.
3042
3150
  const scope = normalizeBlockTouchedFiles(root, deriveNodeFiles(node), toBlockId(nodeId));
3043
3151
  const commands = normalizeBlockTargetedCommands(node.targeted_commands ?? [], toBlockId(nodeId));
3044
3152
  const refusals = [...scope.refusals, ...commands.refusals];