@martinloop/mcp 0.3.0 → 0.3.2

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 (48) hide show
  1. package/README.md +5 -4
  2. package/dist/package-version.d.ts +1 -1
  3. package/dist/package-version.js +1 -1
  4. package/dist/server-validation.js +2 -2
  5. package/dist/server.js +72 -10
  6. package/dist/tools/doctor.d.ts +27 -0
  7. package/dist/tools/doctor.js +39 -11
  8. package/dist/tools/get-run.d.ts +2 -1
  9. package/dist/tools/get-run.js +1 -0
  10. package/dist/tools/get-verification-results.d.ts +2 -1
  11. package/dist/tools/get-verification-results.js +1 -0
  12. package/dist/tools/plan.js +4 -2
  13. package/dist/tools/preflight.d.ts +27 -0
  14. package/dist/tools/preflight.js +44 -20
  15. package/dist/tools/run-dossier.d.ts +2 -1
  16. package/dist/tools/run-dossier.js +1 -0
  17. package/dist/tools/run-loop.d.ts +5 -1
  18. package/dist/tools/run-loop.js +20 -8
  19. package/dist/tools/run-store.js +67 -15
  20. package/dist/tools/tool-support.d.ts +2 -0
  21. package/dist/tools/tool-support.js +49 -13
  22. package/dist/tools/workflow-governance.d.ts +19 -3
  23. package/dist/tools/workflow-governance.js +107 -55
  24. package/dist/vendor/adapters/claude-cli.d.ts +20 -3
  25. package/dist/vendor/adapters/claude-cli.js +193 -33
  26. package/dist/vendor/adapters/cli-bridge.d.ts +45 -0
  27. package/dist/vendor/adapters/cli-bridge.js +107 -39
  28. package/dist/vendor/adapters/codex-launcher.d.ts +32 -0
  29. package/dist/vendor/adapters/codex-launcher.js +409 -118
  30. package/dist/vendor/adapters/openai-compatible.js +8 -2
  31. package/dist/vendor/adapters/runtime-support.js +1 -0
  32. package/dist/vendor/adapters/stub-direct-provider.js +3 -0
  33. package/dist/vendor/adapters/verifier-only.d.ts +2 -0
  34. package/dist/vendor/adapters/verifier-only.js +9 -3
  35. package/dist/vendor/contracts/index.d.ts +2 -1
  36. package/dist/vendor/contracts/index.js +14 -0
  37. package/dist/vendor/core/context-integrity.js +28 -3
  38. package/dist/vendor/core/grounding.d.ts +1 -0
  39. package/dist/vendor/core/grounding.js +6 -2
  40. package/dist/vendor/core/index.d.ts +1 -0
  41. package/dist/vendor/core/index.js +25 -6
  42. package/dist/vendor/core/leash.js +90 -8
  43. package/dist/vendor/core/persistence/integrity.d.ts +1 -1
  44. package/dist/vendor/core/persistence/integrity.js +15 -6
  45. package/dist/workflow-state.d.ts +9 -0
  46. package/dist/workflow-state.js +44 -3
  47. package/package.json +2 -2
  48. package/server.json +2 -2
@@ -144,7 +144,11 @@ export function createOpenAiCompatibleAdapter(options) {
144
144
  async execute(request) {
145
145
  const prompt = buildPrompt(request);
146
146
  const estimated = estimateCost(model, prompt.length, 2000);
147
- const baselineChangedFiles = new Set(await readGitChangedFiles(workingDirectory, 5_000));
147
+ const hasVerificationSteps = request.context.verificationPlan.length > 0 ||
148
+ (request.context.verificationStack?.length ?? 0) > 0;
149
+ const baselineChangedFiles = hasVerificationSteps
150
+ ? new Set(await readGitChangedFiles(workingDirectory, 5_000))
151
+ : new Set();
148
152
  // Preflight: bail if projected cost exceeds remaining budget
149
153
  if (request.context.remainingBudgetUsd > 0 &&
150
154
  estimated.actualUsd > request.context.remainingBudgetUsd * 0.95) {
@@ -238,7 +242,9 @@ export function createOpenAiCompatibleAdapter(options) {
238
242
  // Run verification
239
243
  const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack);
240
244
  const execution = {
241
- changedFiles: (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file))
245
+ changedFiles: hasVerificationSteps
246
+ ? (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file))
247
+ : []
242
248
  };
243
249
  const pricing = KNOWN_MODEL_PRICING[model] ?? {
244
250
  inputPer1K: FALLBACK_INPUT_PER_1K,
@@ -5,6 +5,7 @@ export function createAdapterCapabilities(overrides = {}) {
5
5
  diffArtifacts: false,
6
6
  structuredErrors: false,
7
7
  cachingSignals: false,
8
+ workspaceMutations: true,
8
9
  ...overrides
9
10
  };
10
11
  }
@@ -4,6 +4,9 @@ export function createStubDirectProviderAdapter(options) {
4
4
  providerId: options.providerId,
5
5
  model: options.model,
6
6
  label: options.label ?? `Stub direct provider (${options.providerId}/${options.model})`,
7
+ capabilities: {
8
+ workspaceMutations: false
9
+ },
7
10
  responder: options.responder
8
11
  });
9
12
  }
@@ -1,7 +1,9 @@
1
1
  import type { MartinAdapter } from "../core/index.js";
2
+ import { type SpawnLike } from "./cli-bridge.js";
2
3
  export interface VerifierOnlyAdapterOptions {
3
4
  workingDirectory?: string;
4
5
  verifyTimeoutMs?: number;
5
6
  label?: string;
7
+ spawnImpl?: SpawnLike;
6
8
  }
7
9
  export declare function createVerifierOnlyAdapter(options?: VerifierOnlyAdapterOptions): MartinAdapter;
@@ -17,9 +17,15 @@ export function createVerifierOnlyAdapter(options = {}) {
17
17
  })
18
18
  },
19
19
  async execute(request) {
20
- const baselineChangedFiles = new Set(await readGitChangedFiles(workingDirectory, 5_000));
21
- const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack);
22
- const changedFiles = (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file));
20
+ const shouldTrackVerifierWrites = request.context.verificationPlan.length > 0 ||
21
+ (request.context.verificationStack?.length ?? 0) > 0;
22
+ const baselineChangedFiles = shouldTrackVerifierWrites
23
+ ? new Set(await readGitChangedFiles(workingDirectory, 5_000))
24
+ : new Set();
25
+ const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack, options.spawnImpl);
26
+ const changedFiles = shouldTrackVerifierWrites
27
+ ? (await readGitChangedFiles(workingDirectory, 5_000)).filter((file) => !baselineChangedFiles.has(file))
28
+ : [];
23
29
  const execution = { changedFiles };
24
30
  if (verification.passed) {
25
31
  return {
@@ -1,6 +1,7 @@
1
1
  export type LoopStatus = "queued" | "running" | "verifying" | "completed" | "failed" | "exited";
2
2
  export type LoopLifecycleState = "created" | "running" | "verifying" | "completed" | "budget_exit" | "diminishing_returns" | "stuck_exit" | "human_escalation";
3
- export type FailureClass = "logic_error" | "hallucination" | "syntax_error" | "type_error" | "test_regression" | "scope_creep" | "no_progress" | "repo_grounding_failure" | "verification_failure" | "environment_mismatch" | "budget_pressure" | "safety_leash_blocked";
3
+ export declare const FAILURE_CLASSES: readonly ["logic_error", "hallucination", "syntax_error", "type_error", "test_regression", "scope_creep", "no_progress", "repo_grounding_failure", "verification_failure", "environment_mismatch", "budget_pressure", "safety_leash_blocked"];
4
+ export type FailureClass = (typeof FAILURE_CLASSES)[number];
4
5
  export type InterventionType = "compress_context" | "change_model" | "tighten_task" | "switch_adapter" | "run_verifier" | "escalate_human" | "stop_loop";
5
6
  export type LoopEventType = "run.started" | "attempt.started" | "attempt.completed" | "failure.classified" | "intervention.selected" | "verification.completed" | "budget.updated" | "run.completed";
6
7
  export interface LoopTask {
@@ -1,3 +1,17 @@
1
+ export const FAILURE_CLASSES = [
2
+ "logic_error",
3
+ "hallucination",
4
+ "syntax_error",
5
+ "type_error",
6
+ "test_regression",
7
+ "scope_creep",
8
+ "no_progress",
9
+ "repo_grounding_failure",
10
+ "verification_failure",
11
+ "environment_mismatch",
12
+ "budget_pressure",
13
+ "safety_leash_blocked",
14
+ ];
1
15
  export { MARTIN_ERROR_CATEGORIES } from "./operator.js";
2
16
  export const DEFAULT_BUDGET = {
3
17
  maxUsd: 25,
@@ -10,6 +10,23 @@ const POISON_PATTERNS = [
10
10
  /\[system_override\]/i,
11
11
  /\[authority_inversion\]/i
12
12
  ];
13
+ /**
14
+ * Identity-redefinition / persona-override patterns.
15
+ *
16
+ * These intentionally require an *override framing* (e.g. "now", "no longer",
17
+ * "forget", "pretend", or an explicit authority-role claim) rather than any
18
+ * sentence shaped like "you are X" / "I am X" — the latter matches ordinary
19
+ * benign text (e.g. "You are welcome to try MartinLoop") and produced
20
+ * false-positive hard aborts.
21
+ */
22
+ const IDENTITY_REDEFINITION_PATTERNS = [
23
+ /\byou(?:'re|\s+are)\s+now\s+(?:a|an|the)\b(?!\s+(?:martin\s+loop|ai\s+coding\s+agent))/i,
24
+ /\byou(?:'re|\s+are)\s+no\s+longer\s+(?!.*\b(?:martin\s+loop|an?\s+ai)\b)/i,
25
+ /\bforget\s+(?:that\s+)?you(?:'re|\s+are)\s+martin\s+loop\b/i,
26
+ /\b(?:pretend|imagine)\s+(?:that\s+)?you(?:'re|\s+are)\b/i,
27
+ /\bact\s+as\s+(?:if\s+you(?:'re|\s+are)\s+)?(?:a|an)\s+(?:different|new|unrestricted|jailbroken)\b/i,
28
+ /\bi\s+am\s+(?:the|your)\s+(?:new\s+)?(?:system|developer|admin(?:istrator)?|root\s*user|owner|creator|operator)\b/i
29
+ ];
13
30
  /**
14
31
  * T05: Context Poisoning Pre-gate.
15
32
  * Scans untrusted input channels for authority inversion or instruction re-injection.
@@ -23,7 +40,12 @@ export async function runContextIntegrityPrecheck(runId, attemptIndex, artifacts
23
40
  tools: Boolean(inputs.toolOutput),
24
41
  history: Boolean(inputs.history)
25
42
  };
26
- const untrustedBuffer = [inputs.userPrompt, inputs.toolOutput, inputs.retrievedContext]
43
+ const untrustedBuffer = [
44
+ inputs.userPrompt,
45
+ inputs.toolOutput,
46
+ inputs.retrievedContext,
47
+ inputs.history
48
+ ]
27
49
  .filter(Boolean)
28
50
  .join("\n---\n");
29
51
  for (const pattern of POISON_PATTERNS) {
@@ -31,8 +53,11 @@ export async function runContextIntegrityPrecheck(runId, attemptIndex, artifacts
31
53
  signals.push(`Detected poison pattern: ${pattern.toString()}`);
32
54
  }
33
55
  }
34
- if (/\b(?:I am|You are)\s+(?!Martin\s+Loop|an\s+AI)\b/i.test(untrustedBuffer)) {
35
- signals.push("Identity redefinition attempt detected.");
56
+ for (const pattern of IDENTITY_REDEFINITION_PATTERNS) {
57
+ if (pattern.test(untrustedBuffer)) {
58
+ signals.push("Identity redefinition attempt detected.");
59
+ break;
60
+ }
36
61
  }
37
62
  const verdict = signals.length > 0 ? "context_poisoning_block" : "clean";
38
63
  const precheck = {
@@ -16,6 +16,7 @@ export interface RepoGroundingHit {
16
16
  matchedTerms: string[];
17
17
  symbols: string[];
18
18
  }
19
+ export declare function resolveGroundingRoot(env?: NodeJS.ProcessEnv): string;
19
20
  export declare function loadOrBuildRepoGroundingIndex(repoRoot: string): Promise<RepoGroundingIndex>;
20
21
  export declare function buildRepoGroundingIndex(repoRoot: string): Promise<RepoGroundingIndex>;
21
22
  export declare function queryRepoGroundingIndex(index: RepoGroundingIndex, query: string, limit?: number): RepoGroundingHit[];
@@ -12,6 +12,10 @@ const IGNORED_DIRS = new Set([
12
12
  ]);
13
13
  const MAX_FILE_BYTES = 64_000;
14
14
  const MAX_FILES = 500;
15
+ export function resolveGroundingRoot(env = process.env) {
16
+ return env["MARTIN_GROUNDING_DIR"]?.trim() ??
17
+ join(homedir(), ".martin", "grounding");
18
+ }
15
19
  export async function loadOrBuildRepoGroundingIndex(repoRoot) {
16
20
  const cachePath = getGroundingCachePath(repoRoot);
17
21
  try {
@@ -23,7 +27,7 @@ export async function loadOrBuildRepoGroundingIndex(repoRoot) {
23
27
  catch { }
24
28
  const index = await buildRepoGroundingIndex(repoRoot);
25
29
  try {
26
- await mkdir(join(homedir(), ".martin", "grounding"), { recursive: true });
30
+ await mkdir(resolveGroundingRoot(), { recursive: true });
27
31
  await writeFile(cachePath, JSON.stringify(index, null, 2), "utf8");
28
32
  }
29
33
  catch {
@@ -79,7 +83,7 @@ export function queryRepoGroundingIndex(index, query, limit = 6) {
79
83
  .slice(0, limit);
80
84
  }
81
85
  function getGroundingCachePath(repoRoot) {
82
- return join(homedir(), ".martin", "grounding", `${Buffer.from(repoRoot).toString("base64url")}.json`);
86
+ return join(resolveGroundingRoot(), `${Buffer.from(repoRoot).toString("base64url")}.json`);
83
87
  }
84
88
  async function walk(repoRoot, currentDir, files, state) {
85
89
  if (state.count >= MAX_FILES)
@@ -112,6 +112,7 @@ export interface MartinAdapter {
112
112
  diffArtifacts?: boolean;
113
113
  structuredErrors?: boolean;
114
114
  cachingSignals?: boolean;
115
+ workspaceMutations?: boolean;
115
116
  };
116
117
  [key: string]: unknown;
117
118
  };
@@ -271,8 +271,20 @@ export async function runMartin(input) {
271
271
  // GATHER → ADMIT: run admission control before executing
272
272
  currentPhase = "ADMIT";
273
273
  // T05: Context Integrity Pre-gate — blocks authority inversion / injection before reasoning
274
+ //
275
+ // Untrusted "tool output" re-entering the loop is verifier command output from prior
276
+ // attempts (e.g. test runners echoing attacker-controlled strings). Pull that text from
277
+ // already-persisted verification.completed events so the gate scans what actually
278
+ // re-enters subsequent prompts, matching the documented "tool output / test output" scope.
279
+ const priorVerifierOutput = loop.events
280
+ .filter((event) => event.type === "verification.completed")
281
+ .flatMap((event) => event.payload.steps ?? [])
282
+ .map((step) => step.detail)
283
+ .filter((detail) => Boolean(detail))
284
+ .join("\n---\n");
274
285
  const contextPrecheck = await runContextIntegrityPrecheck(loop.loopId, loop.attempts.length + 1, runDir(resolveActiveRunsRoot(input.store), loop.loopId), {
275
286
  userPrompt: distilled.focus,
287
+ toolOutput: priorVerifierOutput || undefined,
276
288
  history: loop.attempts.map(a => a.summary).join("\n")
277
289
  });
278
290
  if (contextPrecheck.verdict === "context_poisoning_block") {
@@ -412,10 +424,14 @@ export async function runMartin(input) {
412
424
  },
413
425
  previousAttempts: loop.attempts
414
426
  };
415
- const rollbackBoundary = await captureRollbackBoundary({
416
- repoRoot: request.context.repoRoot,
417
- capturedAt: attemptStartedAt
418
- });
427
+ const tracksWorkspaceMutations = request.context.repoRoot !== undefined &&
428
+ executingAdapter.metadata.capabilities?.workspaceMutations !== false;
429
+ const rollbackBoundary = tracksWorkspaceMutations
430
+ ? await captureRollbackBoundary({
431
+ repoRoot: request.context.repoRoot,
432
+ capturedAt: attemptStartedAt
433
+ })
434
+ : undefined;
419
435
  const result = await executingAdapter.execute(request);
420
436
  const attemptCompletedAt = now();
421
437
  const compiledContext = compilePromptPacket(request);
@@ -535,7 +551,8 @@ export async function runMartin(input) {
535
551
  payload: {
536
552
  actualUsd: loop.cost.actualUsd,
537
553
  remainingBudgetUsd: costState.remainingBudgetUsd,
538
- pressure: costState.pressure
554
+ pressure: costState.pressure,
555
+ provenance: getUsageProvenance(result.usage)
539
556
  }
540
557
  }, { now: now(), idFactory });
541
558
  if (input.store) {
@@ -592,7 +609,9 @@ export async function runMartin(input) {
592
609
  }
593
610
  }));
594
611
  }
595
- const changedFiles = resolveChangedFiles(result, request.context.repoRoot);
612
+ const changedFiles = tracksWorkspaceMutations
613
+ ? resolveChangedFiles(result, request.context.repoRoot)
614
+ : [];
596
615
  // Evidence is only reliable when the adapter explicitly reported files OR git actually
597
616
  // returned a non-empty list. A repoRoot alone is insufficient — git may fail (e.g. not
598
617
  // a git repo) and silently return [], which would falsely trigger no_code_change.
@@ -1,20 +1,56 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
2
  const BLOCKED_PATTERNS = [
3
- /(^|\s)rm\s+-rf(\s|$)/u,
3
+ /(^|[\s;&|`(])rm\s+-rf(\s|$)/iu,
4
4
  /git\s+reset\s+--hard/iu,
5
5
  /git\s+clean\s+-fd/iu,
6
6
  /curl\b[^\n|]*\|\s*(sh|bash)/iu,
7
7
  /wget\b[^\n|]*\|\s*(sh|bash)/iu,
8
- /(^|\s)sudo(\s|$)/u,
9
- /(^|\s)mkfs(\.|\s|$)/u,
10
- /(^|\s)dd\s+if=/u,
8
+ /(^|[\s;&|`(])sudo(\s|$)/iu,
9
+ /(^|[\s;&|`(])mkfs(\.|\s|$)/iu,
10
+ /(^|[\s;&|`(])dd\s+if=/iu,
11
11
  /(shutdown|reboot)(\s|$)/iu,
12
- /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/u,
12
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/iu,
13
13
  /chmod\s+-R\s+777\s+\//iu,
14
14
  /(kubectl|docker)\s+.*\b(delete|prune|rm)\b/iu,
15
15
  /ssh\s+/iu,
16
- /scp\s+/iu
16
+ /scp\s+/iu,
17
+ // bash/sh -c "<destructive command>" wrappers — flags the wrapper shape directly,
18
+ // since a naive regex on the outer command misses the destructive inner command.
19
+ /\b(?:bash|sh|zsh)\s+-c\s+["'`]/iu,
20
+ // recursive directory deletion via `find ... -delete` / `find ... -exec rm`
21
+ /\bfind\s+\S+(?:\s+-[a-z-]+(?:\s+\S+)?)*\s+-delete\b/iu,
22
+ /\bfind\s+\S+(?:\s+-[a-z-]+(?:\s+\S+)?)*\s+-exec\s+rm\b/iu,
23
+ // scripting-language recursive deletion one-liners
24
+ /\bshutil\.rmtree\s*\(/iu,
25
+ /\bos\.(?:remove|rmdir|removedirs|unlink)\s*\(/iu,
26
+ /\.(?:rm|rmdir|unlink)(?:Sync)?\s*\(/iu,
27
+ /\brimraf\s*\(/iu,
28
+ // Windows destructive deletion / formatting patterns
29
+ /\b(?:cmd(?:\.exe)?\s+\/c\s+)?del(?:\.exe)?\s+\/[^\n]*(?:\bs\b|\bq\b|\bf\b)/iu,
30
+ /\b(?:cmd(?:\.exe)?\s+\/c\s+)?rmdir(?:\.exe)?\s+\/[^\n]*\bs\b/iu,
31
+ /\bremove-item\b[^\n]*(?:-recurse|-r)\b[^\n]*(?:-force|-fo)\b/iu,
32
+ /\b(?:format-volume|diskpart)\b/iu
17
33
  ];
34
+ /**
35
+ * Detects `rm` invocations that combine recursive + force flags regardless of
36
+ * flag ordering/grouping (`-rf`, `-fr`, `-r -f`, `--recursive --force`),
37
+ * absolute-path invocation (`/bin/rm`, `/usr/bin/rm`), case, or `${IFS}`-style
38
+ * shell obfuscation — all of which slip past the literal `rm\s+-rf` pattern.
39
+ */
40
+ function commandContainsDestructiveRemoval(command) {
41
+ const normalized = command.replace(/\$\{?IFS\}?/giu, " ").toLowerCase();
42
+ const rmInvocation = /(?:^|[\s;&|`(])(?:\/(?:usr\/(?:local\/)?)?s?bin\/)?rm\s+([^\n;|`]+)/giu;
43
+ let match;
44
+ while ((match = rmInvocation.exec(normalized)) !== null) {
45
+ const args = match[1] ?? "";
46
+ const hasRecursive = /(?:^|\s)-[a-z]*r[a-z]*(?:\s|$)|--recursive\b/u.test(args);
47
+ const hasForce = /(?:^|\s)-[a-z]*f[a-z]*(?:\s|$)|--force\b/u.test(args);
48
+ if (hasRecursive && hasForce) {
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ }
18
54
  const SECRET_PATTERNS = [
19
55
  {
20
56
  kind: "secret_value",
@@ -28,7 +64,52 @@ const SECRET_PATTERNS = [
28
64
  },
29
65
  {
30
66
  kind: "secret_value",
31
- pattern: /\bghp_[A-Za-z0-9_]{8,}\b/gu,
67
+ pattern: /\bghp_[A-Za-z0-9_]{16,}\b/gu,
68
+ replacement: "[REDACTED_SECRET]"
69
+ },
70
+ {
71
+ kind: "secret_value",
72
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/gu,
73
+ replacement: "[REDACTED_SECRET]"
74
+ },
75
+ {
76
+ kind: "secret_value",
77
+ pattern: /\b(?:gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b/gu,
78
+ replacement: "[REDACTED_SECRET]"
79
+ },
80
+ {
81
+ kind: "secret_value",
82
+ pattern: /\bAKIA[0-9A-Z]{16}\b/gu,
83
+ replacement: "[REDACTED_SECRET]"
84
+ },
85
+ {
86
+ kind: "secret_value",
87
+ pattern: /\b(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*[^\s"'`]+/giu,
88
+ replacement: "AWS_SECRET_ACCESS_KEY=[REDACTED_SECRET]"
89
+ },
90
+ {
91
+ kind: "secret_value",
92
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/giu,
93
+ replacement: "[REDACTED_SECRET]"
94
+ },
95
+ {
96
+ kind: "secret_value",
97
+ pattern: /\bAIza[0-9A-Za-z_-]{30,}\b/gu,
98
+ replacement: "[REDACTED_SECRET]"
99
+ },
100
+ {
101
+ kind: "secret_value",
102
+ pattern: /-----BEGIN(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----[\s\S]*?-----END(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----/gu,
103
+ replacement: "[REDACTED_SECRET]"
104
+ },
105
+ {
106
+ kind: "secret_value",
107
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu,
108
+ replacement: "[REDACTED_SECRET]"
109
+ },
110
+ {
111
+ kind: "secret_value",
112
+ pattern: /\b(?:api[_-]?key|secret|password|passwd|token)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}["']?/giu,
32
113
  replacement: "[REDACTED_SECRET]"
33
114
  }
34
115
  ];
@@ -55,7 +136,8 @@ export function evaluateVerificationLeash(task) {
55
136
  ...((task.verificationStack ?? []).map((step) => step.command))
56
137
  ].filter(Boolean);
57
138
  const profile = resolveExecutionProfile(task);
58
- const blockedCommands = commands.filter((command) => BLOCKED_PATTERNS.some((pattern) => pattern.test(command)));
139
+ const blockedCommands = commands.filter((command) => BLOCKED_PATTERNS.some((pattern) => pattern.test(command)) ||
140
+ commandContainsDestructiveRemoval(command));
59
141
  const violations = blockedCommands.map((command) => ({
60
142
  kind: "command_blocked",
61
143
  command,
@@ -28,7 +28,7 @@ export declare function writeReceiptIntegrityMaterial(input: {
28
28
  ledgerEntries: unknown[];
29
29
  scope?: ReceiptScope;
30
30
  signedAt?: string;
31
- }): Promise<StoredReceiptIntegrityMaterial>;
31
+ }): Promise<StoredReceiptIntegrityMaterial | undefined>;
32
32
  export declare function verifyReceiptIntegrityFromFiles(input: {
33
33
  runId: string;
34
34
  runsRoot: string;
@@ -7,7 +7,11 @@ const RECEIPT_INTEGRITY_KEY_DIR_MODE = 0o700;
7
7
  const RECEIPT_INTEGRITY_KEY_FILE_MODE = 0o600;
8
8
  export async function writeReceiptIntegrityMaterial(input) {
9
9
  const signedAt = input.signedAt ?? new Date().toISOString();
10
- const [key, keyId] = await ensureReceiptIntegrityKey(input.runsRoot, input.runId);
10
+ const keyMaterial = await ensureReceiptIntegrityKey(input.runsRoot, input.runId);
11
+ if (!keyMaterial) {
12
+ return undefined;
13
+ }
14
+ const [key, keyId] = keyMaterial;
11
15
  const chain = buildReceiptIntegrityChain(input.ledgerEntries);
12
16
  const loopRecordRaw = serializeStoredJson(input.loopRecord);
13
17
  const ledgerRaw = serializeStoredJsonl(input.ledgerEntries);
@@ -210,11 +214,16 @@ async function ensureReceiptIntegrityKey(runsRoot, runId) {
210
214
  return [trimmed, sha256(trimmed).slice(0, 16)];
211
215
  }
212
216
  const generated = randomBytes(32).toString("hex");
213
- await mkdir(dirname(keyPath), { recursive: true, mode: RECEIPT_INTEGRITY_KEY_DIR_MODE });
214
- await writeFile(keyPath, `${generated}\n`, {
215
- encoding: "utf8",
216
- mode: RECEIPT_INTEGRITY_KEY_FILE_MODE
217
- });
217
+ try {
218
+ await mkdir(dirname(keyPath), { recursive: true, mode: RECEIPT_INTEGRITY_KEY_DIR_MODE });
219
+ await writeFile(keyPath, `${generated}\n`, {
220
+ encoding: "utf8",
221
+ mode: RECEIPT_INTEGRITY_KEY_FILE_MODE
222
+ });
223
+ }
224
+ catch {
225
+ return undefined;
226
+ }
218
227
  return [generated, sha256(generated).slice(0, 16)];
219
228
  }
220
229
  async function readReceiptIntegrityKey(runsRoot, runId) {
@@ -1,3 +1,4 @@
1
+ import type { LoopBudget, ReceiptScope } from "./vendor/contracts/index.js";
1
2
  type McpWorkflowStepName = "doctor" | "plan" | "preflight";
2
3
  export interface RecordMcpWorkflowStepInput {
3
4
  runsRoot: string;
@@ -6,6 +7,10 @@ export interface RecordMcpWorkflowStepInput {
6
7
  objective?: string;
7
8
  engine?: string;
8
9
  verificationPlan?: string[];
10
+ receiptScope?: ReceiptScope;
11
+ allowedPaths?: string[];
12
+ deniedPaths?: string[];
13
+ budget?: LoopBudget;
9
14
  }
10
15
  export interface EvaluateMcpRunGateInput {
11
16
  runsRoot: string;
@@ -13,6 +18,10 @@ export interface EvaluateMcpRunGateInput {
13
18
  objective: string;
14
19
  engine?: string;
15
20
  verificationPlan?: string[];
21
+ receiptScope?: ReceiptScope;
22
+ allowedPaths?: string[];
23
+ deniedPaths?: string[];
24
+ budget?: LoopBudget;
16
25
  }
17
26
  export interface McpRunGateResult {
18
27
  allowed: boolean;
@@ -15,7 +15,10 @@ export async function recordMcpWorkflowStep(input) {
15
15
  workingDirectory: normalizeWorkingDirectory(input.workingDirectory),
16
16
  ...(input.objective ? { objectiveKey: normalizeObjective(input.objective) } : {}),
17
17
  ...(input.engine ? { engine: input.engine } : {}),
18
- ...(input.verificationPlan ? { verificationPlanKey: hashVerificationPlan(input.verificationPlan) } : {})
18
+ ...(input.verificationPlan ? { verificationPlanKey: hashVerificationPlan(input.verificationPlan) } : {}),
19
+ ...(input.receiptScope ? { scopeKey: hashReceiptScope(input.receiptScope) } : {}),
20
+ pathScopeKey: hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []),
21
+ ...(input.budget ? { budgetKey: hashBudget(input.budget) } : {})
19
22
  };
20
23
  await writeWorkflowState(input.runsRoot, state);
21
24
  }
@@ -26,18 +29,31 @@ export async function evaluateMcpRunGate(input) {
26
29
  const objectiveKey = normalizeObjective(input.objective);
27
30
  const engine = input.engine ?? "claude";
28
31
  const verificationPlanKey = hashVerificationPlan(input.verificationPlan ?? []);
32
+ const scopeKey = hashReceiptScope(input.receiptScope ?? {
33
+ invocationRoot: input.workingDirectory,
34
+ workingDirectory: input.workingDirectory,
35
+ repoRoot: input.workingDirectory,
36
+ runsRoot: input.runsRoot
37
+ });
38
+ const pathScopeKey = hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []);
39
+ const budgetKey = input.budget ? hashBudget(input.budget) : undefined;
29
40
  const missingSteps = [];
30
- if (!isFresh(mcpState["doctor"], DOCTOR_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory)) {
41
+ if (!isFresh(mcpState["doctor"], DOCTOR_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory &&
42
+ receipt.scopeKey === scopeKey)) {
31
43
  missingSteps.push("doctor");
32
44
  }
33
45
  if (!isFresh(mcpState["plan"], PLAN_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory &&
46
+ receipt.scopeKey === scopeKey &&
34
47
  receipt.objectiveKey === objectiveKey)) {
35
48
  missingSteps.push("plan");
36
49
  }
37
50
  if (!isFresh(mcpState["preflight"], PREFLIGHT_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory &&
38
51
  receipt.objectiveKey === objectiveKey &&
39
52
  receipt.engine === engine &&
40
- receipt.verificationPlanKey === verificationPlanKey)) {
53
+ receipt.verificationPlanKey === verificationPlanKey &&
54
+ receipt.scopeKey === scopeKey &&
55
+ receipt.pathScopeKey === pathScopeKey &&
56
+ (budgetKey === undefined || receipt.budgetKey === budgetKey))) {
41
57
  missingSteps.push("preflight");
42
58
  }
43
59
  if (missingSteps.length === 0) {
@@ -100,3 +116,28 @@ function hashVerificationPlan(verificationPlan) {
100
116
  const normalized = verificationPlan.map((step) => step.trim()).filter(Boolean);
101
117
  return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 12);
102
118
  }
119
+ function hashReceiptScope(receiptScope) {
120
+ const normalized = {
121
+ invocationRoot: normalizeWorkingDirectory(receiptScope.invocationRoot ?? receiptScope.workingDirectory ?? ""),
122
+ workingDirectory: normalizeWorkingDirectory(receiptScope.workingDirectory ?? receiptScope.repoRoot ?? ""),
123
+ repoRoot: normalizeWorkingDirectory(receiptScope.repoRoot ?? receiptScope.workingDirectory ?? ""),
124
+ runsRoot: normalizeWorkingDirectory(receiptScope.runsRoot ?? "")
125
+ };
126
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 12);
127
+ }
128
+ function hashPathScope(allowedPaths, deniedPaths) {
129
+ const normalized = {
130
+ allowedPaths: [...new Set(allowedPaths.map((entry) => entry.trim()).filter(Boolean))].sort(),
131
+ deniedPaths: [...new Set(deniedPaths.map((entry) => entry.trim()).filter(Boolean))].sort()
132
+ };
133
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 12);
134
+ }
135
+ function hashBudget(budget) {
136
+ const normalized = {
137
+ maxUsd: Number(budget.maxUsd.toFixed(4)),
138
+ softLimitUsd: Number(budget.softLimitUsd.toFixed(4)),
139
+ maxIterations: budget.maxIterations,
140
+ maxTokens: budget.maxTokens
141
+ };
142
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 12);
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "mcpName": "io.github.Keesan12/martin-loop",
5
5
  "private": false,
6
6
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  "smoke:published": "node ./scripts/smoke-published-package.mjs",
56
56
  "smoke:published:pack": "node ./scripts/smoke-published-package.mjs --package-spec=pack",
57
57
  "verify:release": "node --test ../../scripts/tests/publish-mcp-workflow.test.mjs ../../scripts/tests/mcp-publish-reliability.test.mjs ../../scripts/tests/mcp-release-docs.test.mjs",
58
- "test": "vitest run",
58
+ "test": "vitest run --maxWorkers=1",
59
59
  "lint": "tsc -p tsconfig.json --noEmit",
60
60
  "start": "node dist/server.js",
61
61
  "inspect:live": "node ./scripts/inspect-live.mjs"
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/Keesan12/martin-loop",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.3.0",
10
+ "version": "0.3.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@martinloop/mcp",
15
- "version": "0.3.0",
15
+ "version": "0.3.2",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }