codecartographer-pi 0.19.5 → 0.20.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 (46) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
@@ -0,0 +1,98 @@
1
+ // Secret redaction for text that leaves the machine (#252).
2
+ //
3
+ // Broad-Side uploads repository content to a batch API as-is; the only thing
4
+ // keeping a `.env` out of a batch was that no language glob matched it. This
5
+ // module is the content-level counterpart: files that exist to hold secrets
6
+ // are skipped by name, and high-confidence secret shapes inside any other
7
+ // file are replaced with `[REDACTED:<kind>]` before the text is sent. The
8
+ // marker keeps the *presence* of a credential visible to the security lens —
9
+ // `password = "[REDACTED:credential-assignment]"` is still a hardcoded
10
+ // credential to flag — while the value stays home.
11
+ //
12
+ // The patterns are deliberately the well-known, low-false-positive ones. A
13
+ // generic entropy scan would redact hashes, ids, and base64 blobs that the
14
+ // lenses need to read, and the point of the pass is to make an accidental
15
+ // upload harmless, not to replace a secret scanner in CI.
16
+ /** A file whose purpose is to hold credentials; never uploaded, whatever a lens's globs say. */
17
+ const SECRET_FILE_PATTERNS = [
18
+ /^\.env(?:\..*)?$/i, // .env, .env.local, .env.production — templates included; they hold values more often than not
19
+ /\.(?:pem|key|p12|pfx|jks|keystore|asc|gpg|ppk)$/i,
20
+ /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?$/, // private keys and their .pub siblings; the public half is noise anyway
21
+ /^(?:\.npmrc|\.pypirc|\.netrc|_netrc|\.htpasswd|\.git-credentials|\.pgpass|\.my\.cnf|\.boto)$/i,
22
+ // credentials.json, secrets.yaml, secret.env — data files by that name.
23
+ // Not `secrets.go` or `credentials.py`: source that *handles* secrets is
24
+ // exactly what the security lens should read, so only data extensions
25
+ // (or none) count here.
26
+ /^(?:credentials?|secrets?)(?:\.(?:json|ya?ml|toml|ini|txt|cfg|conf|properties|env|local))?$/i,
27
+ /\.secret$/i,
28
+ /\.tfvars(?:\.json)?$/i,
29
+ /^service[-_]?account.*\.json$/i,
30
+ ];
31
+ /** Whether a repo-relative path names a file that exists to hold secrets. */
32
+ export function isSecretFile(relPath) {
33
+ const base = relPath.slice(relPath.lastIndexOf("/") + 1);
34
+ return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(base));
35
+ }
36
+ const SECRET_PATTERNS = [
37
+ { kind: "private-key", pattern: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----/g },
38
+ { kind: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
39
+ { kind: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g },
40
+ // OpenAI, OpenRouter (sk-or-v1-…), and Anthropic (sk-ant-…) keys share the prefix.
41
+ { kind: "sk-api-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
42
+ { kind: "stripe-key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
43
+ { kind: "slack-token", pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
44
+ { kind: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
45
+ { kind: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
46
+ // `password: "hunter2hunter2"`, `API_KEY = 'abcdefgh…'`: the key and the
47
+ // quotes stay, the value goes. Eight characters or more, so `password: "x"`
48
+ // in a test fixture is left alone.
49
+ // The key may carry a prefix (`DB_PASSWORD`, `stripe-secret-key`), and a
50
+ // value that is already a marker is left alone, which is what makes the
51
+ // pass idempotent.
52
+ {
53
+ kind: "credential-assignment",
54
+ pattern: /\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|bearer[_-]?token|password|passwd|pwd|secret|token)\s*[:=]\s*)(["'`])(?!\[REDACTED:)([^"'`\r\n]{8,})\2/gi,
55
+ rebuild: (marker, prefix, quote) => `${prefix}${quote}${marker}${quote}`,
56
+ },
57
+ // The password in `scheme://user:password@host`.
58
+ {
59
+ kind: "url-credential",
60
+ pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@"']+:)(?!\[REDACTED:)([^\s/@"']{3,})(@)/gi,
61
+ rebuild: (marker, head, _password, at) => `${head}${marker}${at}`,
62
+ },
63
+ ];
64
+ /**
65
+ * Replace every high-confidence secret in `text` with `[REDACTED:<kind>]`.
66
+ * Idempotent: a marker contains nothing any pattern matches.
67
+ */
68
+ export function redactSecrets(text) {
69
+ let out = text;
70
+ let count = 0;
71
+ const kinds = {};
72
+ for (const { kind, pattern, rebuild } of SECRET_PATTERNS) {
73
+ out = out.replace(pattern, (...match) => {
74
+ count++;
75
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
76
+ const marker = `[REDACTED:${kind}]`;
77
+ if (!rebuild)
78
+ return marker;
79
+ // replace() passes [whole, ...groups, offset, input]; hand over the groups.
80
+ const groups = match.slice(1, match.length - 2).map((value) => (typeof value === "string" ? value : ""));
81
+ return rebuild(marker, ...groups);
82
+ });
83
+ }
84
+ return { text: out, count, kinds };
85
+ }
86
+ /** One line for a submit report: what the pass did, or nothing when it did nothing. */
87
+ export function describeRedactions(values, files, skipped) {
88
+ const parts = [];
89
+ if (values > 0)
90
+ parts.push(`redacted ${values} secret-like value(s) in ${files} file(s)`);
91
+ if (skipped.length > 0) {
92
+ const shown = skipped.slice(0, 3).join(", ");
93
+ parts.push(`skipped ${skipped.length} secret-bearing file(s) by name (${shown}${skipped.length > 3 ? ", …" : ""})`);
94
+ }
95
+ if (parts.length === 0)
96
+ return null;
97
+ return `Before upload: ${parts.join("; ")}.`;
98
+ }
@@ -16,6 +16,14 @@ export declare function autoAssignIds(entries: OpenQuestionEntry[], prefix: stri
16
16
  export declare function ensurePostPipelineArray(value: unknown): PostPipelineEntry[];
17
17
  export declare function ensurePhaseRecord(value: unknown): Record<string, StatusPhase>;
18
18
  export declare function createEmptyStatus(projectName: string, pipelinePath: string, pipeline: PipelineFile): NormalizedStatus;
19
+ /**
20
+ * The one next_actions line for a phase the engine says is eligible. Init,
21
+ * completion, and a pipeline switch all spell it this way.
22
+ */
23
+ export declare function beginPhaseAction(phase: {
24
+ id: string;
25
+ primary_output?: string;
26
+ }): string;
19
27
  /**
20
28
  * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
21
29
  * moment every phase completes is exactly when skills, amendments, publishing,
@@ -36,6 +44,26 @@ export declare function parseHandoff(value: unknown): PhaseHandoff;
36
44
  export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
37
45
  export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
38
46
  export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
39
- export declare function acquireLock(lockPath: string): Promise<{
47
+ /** What {@link acquireLock} hands back: a release that only ever removes its own lock. */
48
+ export interface LockHandle {
40
49
  release: () => Promise<void>;
41
- }>;
50
+ /**
51
+ * Set when acquiring meant breaking a lock older than {@link STALE_LOCK_MS}:
52
+ * the previous holder as its lock file recorded it, for callers that log.
53
+ */
54
+ brokeStale?: {
55
+ pid: number | null;
56
+ since: string | null;
57
+ };
58
+ }
59
+ /**
60
+ * Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
61
+ * and breaking a lock older than {@link STALE_LOCK_MS}.
62
+ *
63
+ * The lock file records `pid`, timestamp, and a per-acquisition token, and
64
+ * release removes the file only while it still carries that token. Without
65
+ * the token, release removed whoever's lock was there: after a stale break
66
+ * the previous holder's release deleted the new holder's lock, and a third
67
+ * writer walked straight in (#227).
68
+ */
69
+ export declare function acquireLock(lockPath: string): Promise<LockHandle>;
@@ -1,6 +1,7 @@
1
1
  // Status normalization, atomic writes, and file-lock primitives. Pure
2
2
  // framework logic shared by every wrapper.
3
- import { open, rm, stat } from "node:fs/promises";
3
+ import { randomBytes } from "node:crypto";
4
+ import { open, readFile, rm, stat } from "node:fs/promises";
4
5
  import { basename, join } from "node:path";
5
6
  import { pathExists, sleep } from "./utils.js";
6
7
  import { loadYamlFile } from "./yaml.js";
@@ -154,12 +155,17 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
154
155
  last_updated: "",
155
156
  schema_version: 1,
156
157
  phases,
157
- next_actions: firstPhaseConfig?.primary_output
158
- ? [`Begin ${firstPhase} phase by producing ${firstPhaseConfig.primary_output}`]
159
- : ["Begin the first pending phase."],
158
+ next_actions: [beginPhaseAction(firstPhaseConfig ?? { id: firstPhase })],
160
159
  post_pipeline: [],
161
160
  };
162
161
  }
162
+ /**
163
+ * The one next_actions line for a phase the engine says is eligible. Init,
164
+ * completion, and a pipeline switch all spell it this way.
165
+ */
166
+ export function beginPhaseAction(phase) {
167
+ return `Begin ${phase.id} phase by producing ${phase.primary_output ?? `findings/${phase.id}/`}`;
168
+ }
163
169
  /**
164
170
  * Spell a tool for both executable surfaces — the shape the scaffold
165
171
  * staleness notice adopted (#177). next_actions is canonical state rendered
@@ -397,13 +403,25 @@ export function applyHandoff(status, handoff) {
397
403
  status.post_pipeline = [...legacyPostPipeline, ...postPipeline.values()];
398
404
  return status;
399
405
  }
406
+ /**
407
+ * Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
408
+ * and breaking a lock older than {@link STALE_LOCK_MS}.
409
+ *
410
+ * The lock file records `pid`, timestamp, and a per-acquisition token, and
411
+ * release removes the file only while it still carries that token. Without
412
+ * the token, release removed whoever's lock was there: after a stale break
413
+ * the previous holder's release deleted the new holder's lock, and a third
414
+ * writer walked straight in (#227).
415
+ */
400
416
  export async function acquireLock(lockPath) {
401
417
  const startedAt = Date.now();
418
+ const token = `${process.pid}.${randomBytes(8).toString("hex")}`;
419
+ let brokeStale;
402
420
  while (true) {
403
421
  try {
404
422
  const handle = await open(lockPath, "wx");
405
423
  try {
406
- await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
424
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n${token}\n`, "utf8");
407
425
  }
408
426
  catch (error) {
409
427
  // A non-EEXIST write failure must not leak the descriptor the
@@ -413,9 +431,8 @@ export async function acquireLock(lockPath) {
413
431
  }
414
432
  await handle.close();
415
433
  return {
416
- release: async () => {
417
- await rm(lockPath, { force: true }).catch(() => undefined);
418
- },
434
+ release: () => releaseOwnedLock(lockPath, token),
435
+ ...(brokeStale && { brokeStale }),
419
436
  };
420
437
  }
421
438
  catch (error) {
@@ -425,6 +442,7 @@ export async function acquireLock(lockPath) {
425
442
  try {
426
443
  const lockStat = await stat(lockPath);
427
444
  if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
445
+ brokeStale = await describeLockHolder(lockPath);
428
446
  await rm(lockPath, { force: true }).catch(() => undefined);
429
447
  continue;
430
448
  }
@@ -439,3 +457,31 @@ export async function acquireLock(lockPath) {
439
457
  }
440
458
  }
441
459
  }
460
+ /**
461
+ * Remove the lock at `lockPath` only if it is still ours. A lock that vanished
462
+ * (someone broke it as stale) or that now carries another holder's token is
463
+ * left alone; one whose content cannot be read is left to go stale rather
464
+ * than removed unverified.
465
+ */
466
+ async function releaseOwnedLock(lockPath, token) {
467
+ let content;
468
+ try {
469
+ content = await readFile(lockPath, "utf8");
470
+ }
471
+ catch {
472
+ return;
473
+ }
474
+ if (content.split(/\r?\n/)[2] !== token)
475
+ return;
476
+ await rm(lockPath, { force: true }).catch(() => undefined);
477
+ }
478
+ async function describeLockHolder(lockPath) {
479
+ try {
480
+ const [pidLine, sinceLine] = (await readFile(lockPath, "utf8")).split(/\r?\n/);
481
+ const pid = Number.parseInt(pidLine ?? "", 10);
482
+ return { pid: Number.isFinite(pid) ? pid : null, since: sinceLine?.trim() || null };
483
+ }
484
+ catch {
485
+ return { pid: null, since: null };
486
+ }
487
+ }
@@ -4,7 +4,7 @@
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { discoverLibrary, listEntries } from "./library.js";
7
- import { loadCodecartoConfig } from "./orchestrator-config.js";
7
+ import { describeConfigProblems, loadCodecartoConfig } from "./orchestrator-config.js";
8
8
  import { pathExists } from "./utils.js";
9
9
  export const SYNTHESIS_PROPOSAL_PATH = "findings/goal-synthesis/proposal.md";
10
10
  export const SYNTHESIS_VISION_INPUT_PATH = "inputs/vision.md";
@@ -88,7 +88,10 @@ export async function runPhasePreflight(state, phase) {
88
88
  if (checks.has("requires-library")) {
89
89
  const config = await loadCodecartoConfig(state.workspaceDir);
90
90
  if (!config.library.path) {
91
- throw new PhasePreflightError(phase.id, "no library.path is configured. Create a library directory with a .codecarto-library marker file, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml. Example config:\n library:\n path: ~/codecarto-library\n publish_confirm: true");
91
+ // When a config file was dropped, that — not a missing key — is the
92
+ // likeliest reason there is no path; say so ahead of the example.
93
+ const problems = config.problems.length > 0 ? `${describeConfigProblems(config).join("\n")}\n` : "";
94
+ throw new PhasePreflightError(phase.id, `${problems}no library.path is configured. Create a library directory with a .codecarto-library marker file, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml. Example config:\n library:\n path: ~/codecarto-library\n publish_confirm: true`);
92
95
  }
93
96
  const marker = await discoverLibrary(config.library.path);
94
97
  if (!marker) {
@@ -44,6 +44,14 @@ export interface UsageTotals {
44
44
  compactions: CompactionTelemetry;
45
45
  }
46
46
  export declare function loadUsage(workspaceDir: string): Promise<UsageFile>;
47
+ /**
48
+ * Append one run. The read-modify-write runs under the file's lock and lands
49
+ * through an atomic write, so concurrent appends (a Pi runner and an MCP host
50
+ * on one workspace, two phases finishing together) each keep their record
51
+ * (#226, #238). A log that exists but does not parse is never rewritten: the
52
+ * lenient {@link loadUsage} is for display, and appending over its empty
53
+ * fallback destroyed every record the file still held.
54
+ */
47
55
  export declare function appendUsageRun(workspaceDir: string, run: UsageRun): Promise<void>;
48
56
  export declare function computeTotals(file: UsageFile): UsageTotals;
49
57
  export declare function computePerPhaseTotals(file: UsageFile): Map<string, UsageTotals>;
@@ -7,9 +7,10 @@
7
7
  // running, and phases run sequentially against this file, so a plain
8
8
  // read-modify-write is safe enough. If parallel-phase dispatch ever ships,
9
9
  // switch this to atomic-rename (see core/workspace.ts for the pattern).
10
- import { readFile, rename, writeFile } from "node:fs/promises";
10
+ import { readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
- import { pathExists } from "./utils.js";
12
+ import { acquireLock } from "./status.js";
13
+ import { atomicWriteFile, pathExists } from "./utils.js";
13
14
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
14
15
  export const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
15
16
  const SCHEMA_VERSION = 1;
@@ -29,13 +30,40 @@ export async function loadUsage(workspaceDir) {
29
30
  return emptyUsage();
30
31
  }
31
32
  }
33
+ /**
34
+ * Append one run. The read-modify-write runs under the file's lock and lands
35
+ * through an atomic write, so concurrent appends (a Pi runner and an MCP host
36
+ * on one workspace, two phases finishing together) each keep their record
37
+ * (#226, #238). A log that exists but does not parse is never rewritten: the
38
+ * lenient {@link loadUsage} is for display, and appending over its empty
39
+ * fallback destroyed every record the file still held.
40
+ */
32
41
  export async function appendUsageRun(workspaceDir, run) {
33
- const current = await loadUsage(workspaceDir);
34
- current.runs.push(run);
35
42
  const path = join(workspaceDir, USAGE_RELATIVE_PATH);
36
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
37
- await writeFile(tempPath, `${stringifySimpleYaml(current)}\n`, "utf8");
38
- await rename(tempPath, path);
43
+ const lock = await acquireLock(`${path}.lock`);
44
+ try {
45
+ const current = await loadUsageForAppend(path);
46
+ current.runs.push(run);
47
+ await atomicWriteFile(path, `${stringifySimpleYaml(current)}\n`);
48
+ }
49
+ finally {
50
+ await lock.release();
51
+ }
52
+ }
53
+ async function loadUsageForAppend(path) {
54
+ if (!(await pathExists(path)))
55
+ return emptyUsage();
56
+ const raw = await readFile(path, "utf8");
57
+ let parsed;
58
+ try {
59
+ parsed = parseSimpleYaml(raw);
60
+ }
61
+ catch (error) {
62
+ const reason = error instanceof Error ? error.message : String(error);
63
+ throw new Error(`Refusing to append to ${USAGE_RELATIVE_PATH}: the existing file does not parse (${reason}). ` +
64
+ "Its records are still in it — move the file aside to start a new log.");
65
+ }
66
+ return normalize(parsed);
39
67
  }
40
68
  export function computeTotals(file) {
41
69
  const totals = emptyTotals();
@@ -1,15 +1,42 @@
1
1
  export declare function sleep(ms: number): Promise<void>;
2
2
  export declare function pathExists(path: string): Promise<boolean>;
3
+ /**
4
+ * A temp-file suffix that is unique within and across processes: pid, a
5
+ * per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
6
+ * collides whenever two writers hit one target inside a millisecond, and the
7
+ * loser's rename then either fails with ENOENT or clobbers the winner (#226).
8
+ */
9
+ export declare function uniqueTempSuffix(): string;
10
+ /**
11
+ * Write `content` to `path` atomically: a uniquely named sibling temp file,
12
+ * then a rename over the target. Readers see the old bytes or the new bytes,
13
+ * never a truncated file. On failure the temp file is removed best-effort and
14
+ * the error propagates. Every framework file that is rewritten in place goes
15
+ * through this so no caller hand-rolls the temp name.
16
+ */
17
+ export declare function atomicWriteFile(path: string, content: string): Promise<void>;
3
18
  export declare function canonicalPath(path: string): Promise<string>;
4
19
  export declare function normalizeForComparison(path: string): string;
5
20
  export declare function isWithinPath(path: string, root: string): boolean;
6
21
  /**
7
- * Symlink-aware version of isWithinPath. Resolves symlinks on both the path
8
- * and root before comparing, preventing bypass via symlinks inside the
9
- * allowed root that point outside it.
22
+ * Resolve a path the way the kernel will when something is written to it:
23
+ * every component that already exists is followed through symlinks
24
+ * (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
25
+ * is applied to the *resolved* prefix, not the spelled one, because
26
+ * `link/..` means the link target's parent on disk. A relative `path` is
27
+ * taken against `base` without normalisation for the same reason.
10
28
  *
11
- * Falls back to lexical isWithinPath if realpath fails (e.g., path does not
12
- * exist yet), which is safe for write targets that haven't been created.
29
+ * `realpath` alone throws for a file that does not exist yet, and a lexical
30
+ * fallback let `.codecarto/link/new.md` through when `link` was a symlink to
31
+ * somewhere outside the workspace — the file landed outside (#223).
32
+ */
33
+ export declare function resolveExistingPrefix(path: string, base?: string): Promise<string>;
34
+ /**
35
+ * Symlink-aware version of isWithinPath for paths that may not exist yet:
36
+ * the existing prefix of `path` is resolved through symlinks
37
+ * ({@link resolveExistingPrefix}), the root through `realpath`, and the two
38
+ * are compared lexically. A symlinked ancestor that points outside the root
39
+ * fails whether or not the target file exists.
13
40
  */
14
41
  export declare function isWithinPathResolved(path: string, root: string): Promise<boolean>;
15
42
  export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
@@ -1,10 +1,10 @@
1
1
  // General-purpose helpers used by yaml/status/prompts and by wrapper-specific
2
2
  // path-boundary enforcement (Pi tool interception, MCP cwd validation).
3
- import { access } from "node:fs/promises";
3
+ import { randomBytes } from "node:crypto";
4
+ import { access, realpath, rename, rm, writeFile } from "node:fs/promises";
4
5
  import { constants } from "node:fs";
5
6
  import { homedir } from "node:os";
6
- import { join, normalize, resolve } from "node:path";
7
- import { realpath } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, normalize, parse, resolve, sep } from "node:path";
8
8
  export function sleep(ms) {
9
9
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
10
10
  }
@@ -17,6 +17,35 @@ export async function pathExists(path) {
17
17
  return false;
18
18
  }
19
19
  }
20
+ let tempSequence = 0;
21
+ /**
22
+ * A temp-file suffix that is unique within and across processes: pid, a
23
+ * per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
24
+ * collides whenever two writers hit one target inside a millisecond, and the
25
+ * loser's rename then either fails with ENOENT or clobbers the winner (#226).
26
+ */
27
+ export function uniqueTempSuffix() {
28
+ tempSequence = (tempSequence + 1) % 0x7fffffff;
29
+ return `${process.pid}.${tempSequence}.${randomBytes(4).toString("hex")}`;
30
+ }
31
+ /**
32
+ * Write `content` to `path` atomically: a uniquely named sibling temp file,
33
+ * then a rename over the target. Readers see the old bytes or the new bytes,
34
+ * never a truncated file. On failure the temp file is removed best-effort and
35
+ * the error propagates. Every framework file that is rewritten in place goes
36
+ * through this so no caller hand-rolls the temp name.
37
+ */
38
+ export async function atomicWriteFile(path, content) {
39
+ const tempPath = `${path}.${uniqueTempSuffix()}.tmp`;
40
+ try {
41
+ await writeFile(tempPath, content, "utf8");
42
+ await rename(tempPath, path);
43
+ }
44
+ catch (error) {
45
+ await rm(tempPath, { force: true }).catch(() => undefined);
46
+ throw error;
47
+ }
48
+ }
20
49
  export async function canonicalPath(path) {
21
50
  try {
22
51
  return await realpath(path);
@@ -43,25 +72,58 @@ export function isWithinPath(path, root) {
43
72
  return normalizedPath.startsWith(prefix);
44
73
  }
45
74
  /**
46
- * Symlink-aware version of isWithinPath. Resolves symlinks on both the path
47
- * and root before comparing, preventing bypass via symlinks inside the
48
- * allowed root that point outside it.
75
+ * Resolve a path the way the kernel will when something is written to it:
76
+ * every component that already exists is followed through symlinks
77
+ * (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
78
+ * is applied to the *resolved* prefix, not the spelled one, because
79
+ * `link/..` means the link target's parent on disk. A relative `path` is
80
+ * taken against `base` without normalisation for the same reason.
49
81
  *
50
- * Falls back to lexical isWithinPath if realpath fails (e.g., path does not
51
- * exist yet), which is safe for write targets that haven't been created.
82
+ * `realpath` alone throws for a file that does not exist yet, and a lexical
83
+ * fallback let `.codecarto/link/new.md` through when `link` was a symlink to
84
+ * somewhere outside the workspace — the file landed outside (#223).
52
85
  */
53
- export async function isWithinPathResolved(path, root) {
54
- try {
55
- const resolvedPath = await realpath(path);
56
- const resolvedRoot = await realpath(root);
57
- return isWithinPath(resolvedPath, resolvedRoot);
58
- }
59
- catch {
60
- // If realpath fails (path doesn't exist yet, broken symlink, etc.),
61
- // fall back to lexical check. For write targets this is safe because
62
- // the parent directory should already be within the root.
63
- return isWithinPath(path, root);
86
+ export async function resolveExistingPrefix(path, base = process.cwd()) {
87
+ const raw = isAbsolute(path) ? path : `${base}${sep}${path}`;
88
+ const { root } = parse(raw);
89
+ const segments = raw
90
+ .slice(root.length)
91
+ .split(/[\\/]+/)
92
+ .filter((segment) => segment !== "" && segment !== ".");
93
+ let current = await canonicalPath(root || sep);
94
+ const tail = [];
95
+ for (const segment of segments) {
96
+ if (segment === "..") {
97
+ if (tail.length > 0)
98
+ tail.pop();
99
+ else
100
+ current = dirname(current);
101
+ continue;
102
+ }
103
+ if (tail.length > 0) {
104
+ // Once one component is missing, nothing below it can exist either.
105
+ tail.push(segment);
106
+ continue;
107
+ }
108
+ try {
109
+ current = await realpath(join(current, segment));
110
+ }
111
+ catch {
112
+ tail.push(segment);
113
+ }
64
114
  }
115
+ return tail.length === 0 ? current : join(current, ...tail);
116
+ }
117
+ /**
118
+ * Symlink-aware version of isWithinPath for paths that may not exist yet:
119
+ * the existing prefix of `path` is resolved through symlinks
120
+ * ({@link resolveExistingPrefix}), the root through `realpath`, and the two
121
+ * are compared lexically. A symlinked ancestor that points outside the root
122
+ * fails whether or not the target file exists.
123
+ */
124
+ export async function isWithinPathResolved(path, root) {
125
+ const [resolvedPath, resolvedRoot] = await Promise.all([resolveExistingPrefix(path), canonicalPath(root)]);
126
+ return isWithinPath(resolvedPath, resolvedRoot);
65
127
  }
66
128
  export function isPlainObject(value) {
67
129
  return typeof value === "object" && value !== null && !Array.isArray(value);