@martinloop/mcp 0.8.6 → 0.8.8

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.
@@ -1,6 +1,6 @@
1
1
  import type { ApprovalPolicy, ExecutionProfile, LoopTask } from "../contracts/index.js";
2
2
  export type SafetySurface = "command" | "filesystem" | "secret" | "spend" | "network" | "dependency";
3
- export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "secret_value" | "protected_path";
3
+ export type SafetyViolationKind = "command_blocked" | "path_not_allowed" | "path_denied" | "path_outside_repo" | "network_blocked" | "dependency_approval_required" | "migration_approval_required" | "config_change_approval_required" | "test_change_approval_required" | "secret_value" | "protected_path";
4
4
  export interface SafetyViolation {
5
5
  kind: SafetyViolationKind;
6
6
  message: string;
@@ -30,6 +30,14 @@ export declare function evaluateVerificationLeash(task: Pick<LoopTask, "verifica
30
30
  export declare function evaluateFilesystemLeash(input: {
31
31
  repoRoot?: string;
32
32
  changedFiles: string[];
33
+ /**
34
+ * Git-ignored files the attempt created or modified. These are checked only
35
+ * against denied paths and protected secret files, never allowed paths, so
36
+ * dependency installs and build output stay unaffected.
37
+ */
38
+ ignoredFiles?: string[];
39
+ /** Symlinks or junctions created inside the repo that resolve outside it. */
40
+ escapingLinks?: string[];
33
41
  allowedPaths?: string[];
34
42
  deniedPaths?: string[];
35
43
  mutationMode?: "edit" | "read_only" | "verify_only";
@@ -43,6 +51,8 @@ export declare function resolveExecutionProfile(input: {
43
51
  }): ResolvedExecutionProfile;
44
52
  export declare function evaluateChangeApprovalLeash(input: {
45
53
  changedFiles: string[];
54
+ /** Existing test files the attempt deleted or removed lines from. */
55
+ weakenedTestFiles?: string[];
46
56
  executionProfile?: ExecutionProfile;
47
57
  approvalPolicy?: ApprovalPolicy;
48
58
  }): SafetyLeashDecision;
@@ -1,4 +1,5 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
+ import { isInsideDependencyOrBuildDirectory } from "./output-directories.js";
2
3
  const BLOCKED_PATTERNS = [
3
4
  /(^|[\s;&|`(])rm\s+-rf(\s|$)/iu,
4
5
  /git\s+reset\s+--hard/iu,
@@ -186,8 +187,13 @@ export function evaluateVerificationLeash(task) {
186
187
  };
187
188
  }
188
189
  export function evaluateFilesystemLeash(input) {
189
- const violations = [];
190
- for (const rawFile of input.changedFiles) {
190
+ const violations = (input.escapingLinks ?? []).map((link) => ({
191
+ kind: "path_outside_repo",
192
+ file: link,
193
+ message: `Link inside the repo points outside the configured repo root: ${link}`
194
+ }));
195
+ const escapingLinks = new Set(input.escapingLinks ?? []);
196
+ for (const rawFile of input.changedFiles.filter((file) => !escapingLinks.has(file))) {
191
197
  const normalized = normalizeChangedFile(rawFile, input.repoRoot);
192
198
  if (normalized.outsideRepo) {
193
199
  violations.push({
@@ -218,6 +224,10 @@ export function evaluateFilesystemLeash(input) {
218
224
  continue;
219
225
  }
220
226
  const allowedPaths = input.allowedPaths ?? [];
227
+ if (isProtectedSecretFile(file) && !isExplicitlyAllowed(file, allowedPaths)) {
228
+ violations.push(protectedSecretViolation(file));
229
+ continue;
230
+ }
221
231
  if (allowedPaths.length > 0 && !allowedPaths.some((pattern) => matchesPathPattern(file, pattern))) {
222
232
  violations.push({
223
233
  kind: "path_not_allowed",
@@ -226,6 +236,25 @@ export function evaluateFilesystemLeash(input) {
226
236
  });
227
237
  }
228
238
  }
239
+ for (const rawFile of input.ignoredFiles ?? []) {
240
+ const file = normalizePathForMatching(rawFile);
241
+ if (!file) {
242
+ continue;
243
+ }
244
+ if ((input.deniedPaths ?? []).some((pattern) => matchesPathPattern(file, pattern))) {
245
+ violations.push({
246
+ kind: "path_denied",
247
+ file,
248
+ message: `Git-ignored file matches a denylisted path: ${file}`
249
+ });
250
+ continue;
251
+ }
252
+ if (!isInsideDependencyOrBuildDirectory(file) &&
253
+ isProtectedSecretFile(file) &&
254
+ !isExplicitlyAllowed(file, input.allowedPaths ?? [])) {
255
+ violations.push(protectedSecretViolation(file));
256
+ }
257
+ }
229
258
  if (violations.length > 0) {
230
259
  return {
231
260
  allowed: false,
@@ -357,6 +386,15 @@ export function evaluateChangeApprovalLeash(input) {
357
386
  });
358
387
  }
359
388
  }
389
+ if (!input.approvalPolicy?.testChanges) {
390
+ for (const file of input.weakenedTestFiles ?? []) {
391
+ violations.push({
392
+ kind: "test_change_approval_required",
393
+ file,
394
+ message: `Removing or rewriting existing test code requires approval: ${file}`
395
+ });
396
+ }
397
+ }
360
398
  if (violations.length > 0) {
361
399
  return {
362
400
  allowed: false,
@@ -366,7 +404,7 @@ export function evaluateChangeApprovalLeash(input) {
366
404
  profile: profile.name,
367
405
  blockedCommands: [],
368
406
  violations,
369
- reason: "Safety leash blocked dependency, migration, or configuration changes that require approval."
407
+ reason: "Safety leash blocked dependency, migration, configuration, or existing-test changes that require approval."
370
408
  };
371
409
  }
372
410
  return {
@@ -408,6 +446,42 @@ function normalizeChangedFile(file, repoRoot) {
408
446
  outsideRepo: false
409
447
  };
410
448
  }
449
+ const SAFE_ENV_TEMPLATE_SUFFIXES = new Set(["example", "sample", "template", "dist", "defaults"]);
450
+ const PRIVATE_KEY_BASENAMES = new Set(["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"]);
451
+ const SECRET_MATERIAL_EXTENSIONS = new Set(["pem", "key", "p12", "pfx"]);
452
+ /** Files that conventionally hold credentials or private key material. */
453
+ function isProtectedSecretFile(file) {
454
+ const leaf = normalizePathForMatching(file).split("/").at(-1) ?? "";
455
+ if (leaf === ".env") {
456
+ return true;
457
+ }
458
+ if (leaf.startsWith(".env.")) {
459
+ return !SAFE_ENV_TEMPLATE_SUFFIXES.has(leaf.slice(".env.".length).toLowerCase());
460
+ }
461
+ if (PRIVATE_KEY_BASENAMES.has(leaf)) {
462
+ return true;
463
+ }
464
+ const extension = leaf.includes(".") ? leaf.split(".").at(-1)?.toLowerCase() : undefined;
465
+ return extension !== undefined && SECRET_MATERIAL_EXTENSIONS.has(extension);
466
+ }
467
+ /**
468
+ * A protected file counts as allowed only when an allowed-path entry targets it
469
+ * specifically; broad wildcards such as `**` or `src/*` do not approve secrets.
470
+ */
471
+ function isExplicitlyAllowed(file, allowedPaths) {
472
+ return allowedPaths.some((pattern) => {
473
+ const leafPattern = normalizePathForMatching(pattern).split("/").at(-1) ?? "";
474
+ return !/^\*+$/u.test(leafPattern) && matchesPathPattern(file, pattern);
475
+ });
476
+ }
477
+ function protectedSecretViolation(file) {
478
+ return {
479
+ kind: "protected_path",
480
+ file,
481
+ message: `Changed file is a protected secret or key file: ${file}. ` +
482
+ "Name it explicitly in allowed paths to permit agent writes."
483
+ };
484
+ }
411
485
  function matchesPathPattern(file, pattern) {
412
486
  const normalizedFile = normalizePathForMatching(file);
413
487
  const normalizedPattern = normalizePathForMatching(pattern);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Directory names that hold dependency installs or build output. Git-ignored
3
+ * content under them is tracked as a single collapsed entry and is not checked
4
+ * file by file for protected secrets.
5
+ */
6
+ export declare const DEPENDENCY_AND_BUILD_DIRECTORIES: ReadonlySet<string>;
7
+ export declare function isInsideDependencyOrBuildDirectory(repoPath: string): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Directory names that hold dependency installs or build output. Git-ignored
3
+ * content under them is tracked as a single collapsed entry and is not checked
4
+ * file by file for protected secrets.
5
+ */
6
+ export const DEPENDENCY_AND_BUILD_DIRECTORIES = new Set([
7
+ "node_modules",
8
+ "bower_components",
9
+ "jspm_packages",
10
+ ".pnpm-store",
11
+ ".yarn",
12
+ ".venv",
13
+ "venv",
14
+ "__pycache__",
15
+ ".mypy_cache",
16
+ ".pytest_cache",
17
+ ".ruff_cache",
18
+ ".tox",
19
+ ".gradle",
20
+ ".next",
21
+ ".nuxt",
22
+ ".turbo",
23
+ ".cache",
24
+ ".parcel-cache",
25
+ "target",
26
+ "dist",
27
+ "build",
28
+ "out",
29
+ "coverage",
30
+ "vendor"
31
+ ]);
32
+ export function isInsideDependencyOrBuildDirectory(repoPath) {
33
+ const segments = repoPath.replace(/\\/gu, "/").split("/").filter(Boolean);
34
+ // A trailing collapsed entry such as `node_modules/` is itself the directory.
35
+ return segments.some((segment) => DEPENDENCY_AND_BUILD_DIRECTORIES.has(segment));
36
+ }
@@ -1,4 +1,4 @@
1
- import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js";
1
+ import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, PolicyPhase } from "../contracts/index.js";
2
2
  export interface FailureAssessment {
3
3
  failureClass: FailureClass;
4
4
  rationale: string;
@@ -106,6 +106,17 @@ export interface BudgetPreflightInput {
106
106
  remainingTokens?: number;
107
107
  /** Adapter-declared estimated floor required to admit a credible first turn. */
108
108
  minimumViableTokens?: number;
109
+ /**
110
+ * Remaining iterations allowed. Used to compute a budget-aware per-attempt cap
111
+ * that reserves capacity for verifier and at least one repair attempt.
112
+ * When omitted, the preflight uses remaining budget divided by a conservative
113
+ * factor to prevent over-allocation on the first attempt.
114
+ */
115
+ remainingIterations?: number;
116
+ /**
117
+ * Explicit per-attempt spending cap. When set, overrides the computed cap.
118
+ * Only use when the caller has specific rate-limiting requirements.
119
+ */
109
120
  perAttemptCapUsd?: number;
110
121
  pricePerMTokenUsd?: number;
111
122
  }
@@ -158,5 +169,11 @@ export interface EvaluatedPatchDecision extends PatchDecisionArtifact {
158
169
  score: PatchScore;
159
170
  }
160
171
  export declare function selectRecoveryRecipe(evidence: EvidenceVector): RecoveryDecision;
172
+ /**
173
+ * Returns true ONLY when every reason code is in the explicit allowlist of
174
+ * retainable soft failures. Unknown or future reason codes default to UNSAFE
175
+ * (fail-closed), preventing silent candidate retention for unrecognized states.
176
+ */
177
+ export declare function isSafeUnverifiedDiscard(reasonCodes: readonly PatchDecisionReasonCode[]): boolean;
161
178
  export declare function evaluatePatchDecision(input: PatchDecisionInput): EvaluatedPatchDecision;
162
179
  export declare function scorePatchDecision(input: PatchDecisionInput): PatchScore;
@@ -244,17 +244,42 @@ export function inferExit(input) {
244
244
  reason: "Oscillating failure pattern detected — loop is cycling without measurable progress."
245
245
  };
246
246
  }
247
+ // C2 — progress-aware repetition check.
248
+ // Two consecutive attempts with the same failureClass are NOT necessarily
249
+ // the same state. If the verifierScore improved (e.g. fewer failing tests),
250
+ // that is measurable progress and we must not stop prematurely.
251
+ // Only stop when the failure class repeated AND there is no measurable improvement:
252
+ // - verifierScore did not increase, AND
253
+ // - candidate diff did not change (candidateRetained with same failure)
247
254
  const lastTwo = input.loop.attempts.slice(-2);
248
- const repeatedFailure = lastTwo.length === 2 &&
255
+ const repeatedFailureClass = lastTwo.length === 2 &&
249
256
  lastTwo.every((attempt) => attempt.failureClass &&
250
257
  attempt.failureClass === lastTwo[0]?.failureClass &&
251
258
  attempt.failureClass === input.lastFailure?.failureClass);
252
- if (repeatedFailure && input.lastFailure) {
259
+ // Measure progress using REAL evidence from actual verifier step results.
260
+ // Multiple signals are checked; any one constituting real progress allows continuation.
261
+ const measurableProgressOccurred = (() => {
262
+ const [prev, curr] = lastTwo;
263
+ if (!prev || !curr)
264
+ return false;
265
+ // Signal 1: verifierScore improved (computed from real passing/total step ratio)
266
+ const prevScore = prev.verifierScore ?? 0;
267
+ const currScore = curr.verifierScore ?? 0;
268
+ if (currScore > prevScore)
269
+ return true;
270
+ // Signal 2: fewer failing verifier steps than previous attempt (monotonic improvement)
271
+ if (prev.failingVerifierStepCount !== undefined &&
272
+ curr.failingVerifierStepCount !== undefined &&
273
+ curr.failingVerifierStepCount < prev.failingVerifierStepCount)
274
+ return true;
275
+ return false;
276
+ })();
277
+ if (repeatedFailureClass && !measurableProgressOccurred && input.lastFailure) {
253
278
  return {
254
279
  shouldExit: true,
255
280
  lifecycleState: "diminishing_returns",
256
281
  status: "exited",
257
- reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts.`
282
+ reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts with no measurable progress (score, step count, or failure surface unchanged).`
258
283
  };
259
284
  }
260
285
  if (input.lastFailure?.failureClass === "environment_mismatch" &&
@@ -398,11 +423,34 @@ export function evaluateBudgetPreflight(input) {
398
423
  estimate
399
424
  };
400
425
  }
401
- const perAttemptCap = input.perAttemptCapUsd ?? Math.max(input.remainingBudgetUsd * 0.2, 0.05);
426
+ // D2 — Budget-aware per-attempt cap.
427
+ //
428
+ // Old behavior: rigid maxUsd * 0.25 — blocked viable attempts when total budget
429
+ // was large (e.g. $0.30 cost blocked on $1.00 budget because 0.30 > 0.25).
430
+ //
431
+ // New behavior: if an explicit cap is provided, honor it. Otherwise compute a
432
+ // cap that reserves budget for the verifier and at least one repair attempt beyond
433
+ // the current one, giving the provider meaningful runway.
434
+ //
435
+ // Formula: cap = remainingBudget / (remainingIterations + 1)
436
+ // +1 accounts for the current attempt being the "next" attempt.
437
+ // Minimum cap of $0.05 prevents degenerate near-zero rejections.
438
+ const perAttemptCap = (() => {
439
+ if (input.perAttemptCapUsd !== undefined)
440
+ return input.perAttemptCapUsd;
441
+ const iters = input.remainingIterations;
442
+ if (iters !== undefined && iters > 0) {
443
+ // Divide remaining budget evenly across remaining iterations.
444
+ return Math.max(roundUsd(input.remainingBudgetUsd / iters), 0.05);
445
+ }
446
+ // No iteration information: allow up to 80% of remaining budget per attempt
447
+ // (down from the old 25%-of-total which was frequently too low).
448
+ return Math.max(roundUsd(input.remainingBudgetUsd * 0.8), 0.05);
449
+ })();
402
450
  if (estimatedAttemptCostUsd > perAttemptCap) {
403
451
  return {
404
452
  allowed: false,
405
- reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)}.`,
453
+ reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)} (remaining budget $${input.remainingBudgetUsd}, ${input.remainingIterations ?? "?"} iterations left).`,
406
454
  estimate
407
455
  };
408
456
  }
@@ -517,6 +565,41 @@ export function selectRecoveryRecipe(evidence) {
517
565
  intervention: "escalate_human"
518
566
  };
519
567
  }
568
+ /**
569
+ * Explicit allowlist of reason codes that are SAFE to retain as an unverified
570
+ * candidate for the next repair attempt.
571
+ *
572
+ * FAIL-CLOSED: any reason code NOT in this set causes an immediate rollback.
573
+ * This prevents future or unknown reason codes from silently becoming "safe."
574
+ *
575
+ * Retainable only when ALL of these are also true:
576
+ * - scope_violation is absent (agent stayed within allowed paths)
577
+ * - safety_violation is absent (no secret/tenancy/test-integrity violation)
578
+ * - workspace observation was authoritative (changed files were tracked)
579
+ * - provider did not crash/fail (status !== "failed")
580
+ *
581
+ * no_code_change is explicitly NOT retainable — there is nothing to repair.
582
+ * Use a different recovery strategy instead.
583
+ */
584
+ const RETAINABLE_SOFT_REASON_CODES = new Set([
585
+ "verifier_not_improved",
586
+ "low_novelty_no_progress",
587
+ "large_diff_no_improvement",
588
+ "grounding_failure",
589
+ "verifier_regressed", // actual test regression — retry with different approach
590
+ ]);
591
+ /**
592
+ * Returns true ONLY when every reason code is in the explicit allowlist of
593
+ * retainable soft failures. Unknown or future reason codes default to UNSAFE
594
+ * (fail-closed), preventing silent candidate retention for unrecognized states.
595
+ */
596
+ export function isSafeUnverifiedDiscard(reasonCodes) {
597
+ if (reasonCodes.length === 0) {
598
+ // No specific signal — we don't know why it's being discarded; fail closed.
599
+ return false;
600
+ }
601
+ return reasonCodes.every((code) => RETAINABLE_SOFT_REASON_CODES.has(code));
602
+ }
520
603
  export function evaluatePatchDecision(input) {
521
604
  const score = scorePatchDecision(input);
522
605
  const reasonCodes = [...score.reasonCodes];
@@ -624,21 +707,41 @@ function tokenizeDiff(diff) {
624
707
  return tokens;
625
708
  }
626
709
  function decidePatchOutcome(input, reasonCodes) {
710
+ // 1. Explicit human approval required — always escalate regardless of verifier.
711
+ // 2. Safety violation — always escalate.
627
712
  if (input.humanApprovalRequired || reasonCodes.includes("safety_violation")) {
628
713
  return "ESCALATE";
629
714
  }
715
+ // 3. Scope violation — hard boundary: out-of-scope writes are never kept,
716
+ // even if the verifier passes, because the change violated the task contract.
717
+ if (reasonCodes.includes("scope_violation")) {
718
+ return "DISCARD";
719
+ }
720
+ // 4. Required mutation is absent — a green generic test suite is not proof
721
+ // that the requested work happened. Discard if:
722
+ // - a code change was required (mutation_required = edit mode)
723
+ // - no files were actually changed
724
+ // - the task was not pre-satisfied by the existing codebase
725
+ // Note: the scorer already suppresses no_code_change when
726
+ // definitionOfDonePreSatisfied is true, so that legitimate case passes through.
727
+ if (reasonCodes.includes("no_code_change")) {
728
+ return "DISCARD";
729
+ }
730
+ // 5. Passing verifier is the authoritative signal and overrides lower-confidence
731
+ // heuristics such as grounding failure. A grounding heuristic (symbol
732
+ // resolution, repo anatomy check) is weaker evidence than an actual passing
733
+ // host-run test exit code.
734
+ if (input.verificationPassed) {
735
+ return "KEEP";
736
+ }
737
+ // 6. Non-passing verifier: discard on any remaining negative heuristic signal.
630
738
  if (reasonCodes.includes("grounding_failure") ||
631
- reasonCodes.includes("scope_violation") ||
632
- reasonCodes.includes("no_code_change") ||
633
739
  reasonCodes.includes("verifier_regressed") ||
634
740
  reasonCodes.includes("large_diff_no_improvement") ||
635
741
  reasonCodes.includes("low_novelty_no_progress") ||
636
742
  reasonCodes.includes("verifier_not_improved")) {
637
743
  return "DISCARD";
638
744
  }
639
- if (input.verificationPassed) {
640
- return "KEEP";
641
- }
642
745
  return "DISCARD";
643
746
  }
644
747
  function buildPatchDecisionSummary(decision, reasonCodes, summary) {
@@ -1,18 +1,42 @@
1
1
  import type { PatchDecision, RollbackBoundaryArtifact, RollbackOutcomeArtifact } from "../contracts/index.js";
2
+ export interface IgnoredFileChanges {
3
+ created: string[];
4
+ modified: string[];
5
+ deleted: string[];
6
+ }
2
7
  export declare class RollbackStateUnavailableError extends Error {
3
8
  constructor(message: string);
4
9
  }
5
10
  export declare function listAttemptChangedFilesSinceBoundary(input: {
6
11
  repoRoot?: string;
7
12
  boundary?: RollbackBoundaryArtifact;
13
+ /** Include content changes to dirty/untracked paths that existed at the boundary. */
14
+ includeBaselineSnapshotChanges?: boolean;
8
15
  }): string[];
9
16
  export declare function captureRollbackBoundary(input: {
10
17
  repoRoot?: string;
11
18
  capturedAt: string;
12
19
  }): Promise<RollbackBoundaryArtifact | undefined>;
20
+ /**
21
+ * Git-ignored files the attempt created or modified since the boundary.
22
+ * Boundaries recorded before the ignored inventory existed report nothing.
23
+ */
24
+ export declare function listIgnoredFileChangesSinceBoundary(input: {
25
+ repoRoot?: string;
26
+ boundary?: RollbackBoundaryArtifact;
27
+ }): IgnoredFileChanges;
13
28
  export declare function restoreRollbackBoundary(input: {
14
29
  repoRoot?: string;
15
30
  boundary?: RollbackBoundaryArtifact;
16
31
  restoredAt: string;
17
32
  decision: PatchDecision;
33
+ /** Extra attempt-created paths (for example ignored files) to delete. */
34
+ removePaths?: string[];
35
+ /** Pre-existing ignored paths to restore from the in-memory boundary. */
36
+ restoreIgnoredPaths?: string[];
18
37
  }): Promise<RollbackOutcomeArtifact | undefined>;
38
+ /**
39
+ * Repo-relative links (symlinks or junctions) among `paths` whose target
40
+ * resolves outside the repository. Paths are first collapsed to the link.
41
+ */
42
+ export declare function findEscapingLinks(repoRoot: string, paths: string[]): string[];