@praneeth_54/agentdoctor 2.0.0 → 2.0.1

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 (95) hide show
  1. package/CHANGELOG.md +73 -4
  2. package/README.md +283 -158
  3. package/dist/architecture/contract.d.ts +64 -0
  4. package/dist/architecture/contract.js +373 -0
  5. package/dist/assurance/change.d.ts +211 -0
  6. package/dist/assurance/change.js +624 -0
  7. package/dist/assurance/proof.d.ts +136 -0
  8. package/dist/assurance/proof.js +338 -0
  9. package/dist/auth/index.d.ts +81 -0
  10. package/dist/auth/index.js +130 -0
  11. package/dist/auth/rbac.d.ts +9 -0
  12. package/dist/auth/rbac.js +26 -0
  13. package/dist/cli/commands/architecture.d.ts +6 -0
  14. package/dist/cli/commands/architecture.js +109 -0
  15. package/dist/cli/commands/assurance.d.ts +67 -0
  16. package/dist/cli/commands/assurance.js +210 -0
  17. package/dist/cli/commands/complete.js +2 -2
  18. package/dist/cli/commands/platform.d.ts +2 -0
  19. package/dist/cli/commands/platform.js +5 -1
  20. package/dist/cli/commands/policy-graph-run.d.ts +40 -0
  21. package/dist/cli/commands/policy-graph-run.js +200 -0
  22. package/dist/cli/commands/workspace.d.ts +9 -0
  23. package/dist/cli/commands/workspace.js +82 -0
  24. package/dist/cli/program.js +535 -17
  25. package/dist/constants.d.ts +1 -1
  26. package/dist/constants.js +1 -1
  27. package/dist/contracts/index.d.ts +1 -1
  28. package/dist/contracts/index.js +2 -2
  29. package/dist/core/brain-product/init.js +5 -2
  30. package/dist/core/secrets/scan.d.ts +7 -1
  31. package/dist/core/secrets/scan.js +9 -9
  32. package/dist/coverage/cobertura.d.ts +6 -0
  33. package/dist/coverage/cobertura.js +52 -0
  34. package/dist/coverage/istanbul.d.ts +7 -0
  35. package/dist/coverage/istanbul.js +134 -0
  36. package/dist/coverage/lcov.d.ts +7 -0
  37. package/dist/coverage/lcov.js +59 -0
  38. package/dist/coverage/load.d.ts +9 -0
  39. package/dist/coverage/load.js +39 -0
  40. package/dist/coverage/types.d.ts +23 -0
  41. package/dist/coverage/types.js +5 -0
  42. package/dist/dashboard/server.d.ts +1 -1
  43. package/dist/dashboard/server.js +21 -5
  44. package/dist/enforcement/runner.d.ts +47 -3
  45. package/dist/enforcement/runner.js +420 -16
  46. package/dist/index.d.ts +23 -2
  47. package/dist/index.js +15 -2
  48. package/dist/intelligence/graph/build.d.ts +2 -0
  49. package/dist/intelligence/graph/build.js +49 -5
  50. package/dist/intelligence/graph/incremental.d.ts +68 -0
  51. package/dist/intelligence/graph/incremental.js +234 -0
  52. package/dist/intelligence/resolve/imports.d.ts +36 -0
  53. package/dist/intelligence/resolve/imports.js +245 -0
  54. package/dist/languages/go.d.ts +9 -0
  55. package/dist/languages/go.js +45 -0
  56. package/dist/languages/index.d.ts +11 -0
  57. package/dist/languages/index.js +76 -0
  58. package/dist/languages/php.d.ts +7 -0
  59. package/dist/languages/php.js +193 -0
  60. package/dist/languages/python.d.ts +7 -0
  61. package/dist/languages/python.js +150 -0
  62. package/dist/languages/types.d.ts +42 -0
  63. package/dist/languages/types.js +26 -0
  64. package/dist/languages/typescript.d.ts +3 -0
  65. package/dist/languages/typescript.js +95 -0
  66. package/dist/mcp/intelligence/handlers.d.ts +5 -0
  67. package/dist/mcp/intelligence/handlers.js +229 -40
  68. package/dist/mcp/intelligence/path-safety.d.ts +2 -4
  69. package/dist/mcp/intelligence/path-safety.js +25 -44
  70. package/dist/mcp/intelligence/registry.d.ts +1 -1
  71. package/dist/mcp/intelligence/registry.js +69 -1
  72. package/dist/platform/firewall/evaluate.d.ts +6 -3
  73. package/dist/platform/firewall/evaluate.js +66 -15
  74. package/dist/platform/graph/build.js +19 -23
  75. package/dist/platform/health/analyze.js +23 -17
  76. package/dist/platform/index.js +2 -0
  77. package/dist/platform/test-impact/analyze.d.ts +21 -3
  78. package/dist/platform/test-impact/analyze.js +214 -28
  79. package/dist/platform/tokens/plan.js +39 -20
  80. package/dist/platform/types.d.ts +9 -0
  81. package/dist/policy/compose.d.ts +34 -0
  82. package/dist/policy/compose.js +118 -0
  83. package/dist/policy/packs.js +33 -1
  84. package/dist/security/paths.d.ts +21 -0
  85. package/dist/security/paths.js +105 -0
  86. package/dist/storage/postgres.d.ts +26 -0
  87. package/dist/storage/postgres.js +90 -0
  88. package/dist/storage/provider.d.ts +2 -7
  89. package/dist/storage/provider.js +22 -16
  90. package/dist/storage/sqlite.d.ts +33 -0
  91. package/dist/storage/sqlite.js +77 -0
  92. package/dist/team/auth.js +1 -1
  93. package/dist/workspace/index.d.ts +69 -0
  94. package/dist/workspace/index.js +220 -0
  95. package/package.json +9 -3
@@ -1,3 +1,4 @@
1
+ import { constants as fsConstants } from "node:fs";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { resolveRepoRoot, isPathInsideRoot } from "../../utils/path.js";
@@ -42,29 +43,47 @@ export async function planContext(options) {
42
43
  excluded.push({ path: rel, reason: "Path escapes repository root" });
43
44
  continue;
44
45
  }
45
- const st = await fs.stat(absolute);
46
- const lst = await fs.lstat(absolute);
47
- if (lst.isSymbolicLink()) {
48
- excluded.push({ path: rel, reason: "Symlink excluded" });
49
- continue;
46
+ // Open first (O_NOFOLLOW rejects symlinks), then fstat+read from the same fd — no TOCTOU.
47
+ const openFlags = typeof fsConstants.O_NOFOLLOW === "number"
48
+ ? fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW
49
+ : fsConstants.O_RDONLY;
50
+ let handle;
51
+ try {
52
+ handle = await fs.open(absolute, openFlags);
50
53
  }
51
- if (st.size > 100_000) {
52
- excluded.push({ path: rel, reason: "File too large for context budget" });
53
- continue;
54
+ catch (error) {
55
+ const code = error && typeof error === "object" && "code" in error
56
+ ? String(error.code)
57
+ : "";
58
+ if (code === "ELOOP") {
59
+ excluded.push({ path: rel, reason: "Symlink excluded" });
60
+ continue;
61
+ }
62
+ throw error;
54
63
  }
55
- const text = await fs.readFile(absolute, "utf8");
56
- const tokens = estimateTokens(text);
57
- if (used + tokens > budget) {
58
- excluded.push({ path: rel, reason: "Would exceed token budget" });
59
- continue;
64
+ try {
65
+ const st = await handle.stat();
66
+ if (st.size > 100_000) {
67
+ excluded.push({ path: rel, reason: "File too large for context budget" });
68
+ continue;
69
+ }
70
+ const text = await handle.readFile("utf8");
71
+ const tokens = estimateTokens(text);
72
+ if (used + tokens > budget) {
73
+ excluded.push({ path: rel, reason: "Would exceed token budget" });
74
+ continue;
75
+ }
76
+ selected.push({
77
+ path: rel,
78
+ tokens,
79
+ relevance,
80
+ reason: relevance >= 0.6 ? "Path matches query" : "Likely relevant source/doc",
81
+ });
82
+ used += tokens;
83
+ }
84
+ finally {
85
+ await handle.close();
60
86
  }
61
- selected.push({
62
- path: rel,
63
- tokens,
64
- relevance,
65
- reason: relevance >= 0.6 ? "Path matches query" : "Likely relevant source/doc",
66
- });
67
- used += tokens;
68
87
  }
69
88
  catch {
70
89
  excluded.push({ path: rel, reason: "Unreadable" });
@@ -76,6 +76,7 @@ export interface PlatformSnapshot {
76
76
  /** Persisted subset of Module H test-impact analysis. */
77
77
  export interface TestImpactSnapshot {
78
78
  gitAvailable: boolean;
79
+ mode: "coverage-backed" | "heuristic";
79
80
  changedFiles: string[];
80
81
  recommendedTests: Array<{
81
82
  testPath: string;
@@ -93,6 +94,14 @@ export interface TestImpactSnapshot {
93
94
  }>;
94
95
  uncoveredAreas: string[];
95
96
  skipRisk: string;
97
+ coverage: {
98
+ format: string;
99
+ filesWithHits: number;
100
+ changedCoveredLines: number;
101
+ changedUncoveredLines: number;
102
+ coverageHits: number;
103
+ testAttribution: "coverage-map" | "hybrid-heuristic" | "none";
104
+ } | null;
96
105
  limitations: string[];
97
106
  }
98
107
  export interface ReadinessCategory {
@@ -0,0 +1,34 @@
1
+ import type { FirewallPolicyDocument } from "../platform/firewall/evaluate.js";
2
+ export interface ComposedPolicyResult {
3
+ policy: FirewallPolicyDocument;
4
+ sources: Array<"builtin" | "repo">;
5
+ packName: string | null;
6
+ validationError?: string;
7
+ failClosed: boolean;
8
+ }
9
+ /**
10
+ * Merge builtin (global pack) + repo `.agentdoctor/policy.json`.
11
+ * Precedence: repo rules are prepended (evaluated first); repo defaultDecision /
12
+ * failClosed / shellAllowlist override builtin when present.
13
+ * Malformed repo policy → fail-closed deny-all (never silently ignore).
14
+ */
15
+ export declare function composePolicy(options: {
16
+ root: string;
17
+ packName?: string;
18
+ }): Promise<ComposedPolicyResult>;
19
+ export declare function explainComposedDecision(options: {
20
+ command: string;
21
+ decision: string;
22
+ reason: string;
23
+ policyId?: string | null;
24
+ sources: Array<"builtin" | "repo">;
25
+ failClosed: boolean;
26
+ }): {
27
+ command: string;
28
+ decision: string;
29
+ reason: string;
30
+ policyId: string | null;
31
+ sources: Array<"builtin" | "repo">;
32
+ failClosed: boolean;
33
+ meaning: string;
34
+ };
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getPolicyPack, POLICY_PACKS } from "./packs.js";
4
+ import { resolveRepoRoot } from "../utils/path.js";
5
+ const REPO_POLICY_PATH = ".agentdoctor/policy.json";
6
+ function denyAll(reason) {
7
+ return {
8
+ version: "2.0",
9
+ defaultDecision: "block",
10
+ failClosed: true,
11
+ rules: [
12
+ {
13
+ id: "compose-fail-closed",
14
+ decision: "block",
15
+ reason,
16
+ riskLevel: "critical",
17
+ },
18
+ ],
19
+ };
20
+ }
21
+ function isFirewallPolicyDocument(value) {
22
+ if (!value || typeof value !== "object")
23
+ return false;
24
+ const doc = value;
25
+ if (doc.version !== "2.0")
26
+ return false;
27
+ if (typeof doc.defaultDecision !== "string")
28
+ return false;
29
+ if (!Array.isArray(doc.rules))
30
+ return false;
31
+ return true;
32
+ }
33
+ /**
34
+ * Merge builtin (global pack) + repo `.agentdoctor/policy.json`.
35
+ * Precedence: repo rules are prepended (evaluated first); repo defaultDecision /
36
+ * failClosed / shellAllowlist override builtin when present.
37
+ * Malformed repo policy → fail-closed deny-all (never silently ignore).
38
+ */
39
+ export async function composePolicy(options) {
40
+ const root = resolveRepoRoot(options.root);
41
+ const packName = options.packName ?? "baseline-safe";
42
+ const builtin = getPolicyPack(packName) ?? POLICY_PACKS["baseline-safe"];
43
+ const sources = ["builtin"];
44
+ const repoPath = path.join(root, REPO_POLICY_PATH);
45
+ let raw = null;
46
+ try {
47
+ raw = await fs.readFile(repoPath, "utf8");
48
+ }
49
+ catch {
50
+ raw = null;
51
+ }
52
+ if (raw === null) {
53
+ return {
54
+ policy: structuredClone(builtin),
55
+ sources,
56
+ packName,
57
+ failClosed: Boolean(builtin.failClosed),
58
+ };
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return {
66
+ policy: denyAll("Malformed repo .agentdoctor/policy.json; fail-closed"),
67
+ sources: ["builtin", "repo"],
68
+ packName,
69
+ validationError: "Malformed JSON",
70
+ failClosed: true,
71
+ };
72
+ }
73
+ if (!isFirewallPolicyDocument(parsed)) {
74
+ return {
75
+ policy: denyAll("Invalid repo .agentdoctor/policy.json shape; fail-closed"),
76
+ sources: ["builtin", "repo"],
77
+ packName,
78
+ validationError: "Invalid policy document",
79
+ failClosed: true,
80
+ };
81
+ }
82
+ sources.push("repo");
83
+ const repoRules = Array.isArray(parsed.rules) ? parsed.rules : [];
84
+ const merged = {
85
+ version: "2.0",
86
+ defaultDecision: parsed.defaultDecision ?? builtin.defaultDecision,
87
+ failClosed: parsed.failClosed === true || builtin.failClosed === true,
88
+ ...(parsed.shellAllowlist
89
+ ? { shellAllowlist: parsed.shellAllowlist }
90
+ : builtin.shellAllowlist
91
+ ? { shellAllowlist: builtin.shellAllowlist }
92
+ : {}),
93
+ // Repo rules first (higher precedence), then builtin.
94
+ rules: [...repoRules, ...builtin.rules],
95
+ };
96
+ return {
97
+ policy: merged,
98
+ sources,
99
+ packName,
100
+ failClosed: Boolean(merged.failClosed),
101
+ };
102
+ }
103
+ export function explainComposedDecision(options) {
104
+ const meaning = options.decision === "allow"
105
+ ? "Allowlisted or matched an allow rule — still evaluate-only unless agentdoctor run executes it."
106
+ : options.decision === "block" || options.decision === "deny-network"
107
+ ? "Would be blocked by the AgentDoctor-controlled runner if executed through it."
108
+ : "Requires human approval or further policy before controlled execution.";
109
+ return {
110
+ command: options.command,
111
+ decision: options.decision,
112
+ reason: options.reason,
113
+ policyId: options.policyId ?? null,
114
+ sources: options.sources,
115
+ failClosed: options.failClosed,
116
+ meaning,
117
+ };
118
+ }
@@ -21,11 +21,43 @@ export const POLICY_PACKS = {
21
21
  {
22
22
  id: "pack-block-destructive",
23
23
  actionTypes: ["shell"],
24
- match: { commandContains: ["rm -rf", "git push --force", "drop table"] },
24
+ // Force-push is gated as require-approval (human gate), not a hard block —
25
+ // builtin shell blocks already cover rm -rf; keep drop table here.
26
+ match: { commandContains: ["rm -rf", "drop table"] },
25
27
  decision: "block",
26
28
  reason: "baseline-safe: destructive operation",
27
29
  riskLevel: "critical",
28
30
  },
31
+ {
32
+ id: "pack-force-push-approval",
33
+ actionTypes: ["shell"],
34
+ match: { commandContains: ["git push --force", "git push -f"] },
35
+ decision: "require-approval",
36
+ reason: "baseline-safe: force-push requires approval",
37
+ riskLevel: "high",
38
+ },
39
+ {
40
+ id: "pack-allow-safe-dev-paths",
41
+ actionTypes: ["file-modify", "file-create"],
42
+ match: { pathPrefix: ["src/", "tests/", "docs/"] },
43
+ decision: "allow",
44
+ reason: "baseline-safe: allowlisted development paths",
45
+ riskLevel: "low",
46
+ },
47
+ {
48
+ id: "pack-deny-prod-deploy",
49
+ actionTypes: ["deploy", "infra"],
50
+ decision: "block",
51
+ reason: "baseline-safe: production/infra changes require out-of-band process",
52
+ riskLevel: "critical",
53
+ },
54
+ {
55
+ id: "pack-network-default-deny",
56
+ actionTypes: ["network"],
57
+ decision: "deny-network",
58
+ reason: "baseline-safe: network access denied by default",
59
+ riskLevel: "high",
60
+ },
29
61
  ],
30
62
  },
31
63
  "fail-closed-ci": {
@@ -0,0 +1,21 @@
1
+ export declare class PathEscapeError extends Error {
2
+ readonly code = "PATH_ESCAPE";
3
+ constructor(message?: string);
4
+ }
5
+ declare function tryDecodeUri(value: string): string | null;
6
+ /**
7
+ * Lexical hostility checks shared by resolve/assert helpers.
8
+ * Does not leak the candidate path in thrown messages.
9
+ */
10
+ export declare function rejectHostilePathInput(candidate: string): void;
11
+ /**
12
+ * Resolve a user-supplied path and require it to remain inside the repository root.
13
+ * Rejects `..`, URI-encoded traversal, null bytes, Windows-drive absolutes, and
14
+ * symlink realpath escapes. Error messages never echo the hostile candidate.
15
+ */
16
+ export declare function resolveSafeRepoPath(rootInput: string, candidate: string): string;
17
+ /** Assert `candidate` resolves inside `root`; throws PathEscapeError otherwise. */
18
+ export declare function assertInsideRepo(rootInput: string, candidate: string): void;
19
+ /** Resolve safely and return a POSIX path relative to the repo root. */
20
+ export declare function safeRelPath(rootInput: string, absoluteOrRel: string): string;
21
+ export { tryDecodeUri as tryDecodeUriComponent };
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveRepoRoot, isPathInsideRoot, toPosixRelative } from "../utils/path.js";
4
+ export class PathEscapeError extends Error {
5
+ code = "PATH_ESCAPE";
6
+ constructor(message = "path escapes repository root") {
7
+ super(message);
8
+ this.name = "PathEscapeError";
9
+ }
10
+ }
11
+ function tryDecodeUri(value) {
12
+ try {
13
+ return decodeURIComponent(value);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function hasTraversalSegment(value) {
20
+ const normalized = value.replace(/\\/g, "/");
21
+ if (normalized.includes("\0"))
22
+ return true;
23
+ // Reject any `..` occurrence (including encoded forms after decode and `....` tricks).
24
+ if (normalized.includes(".."))
25
+ return true;
26
+ return false;
27
+ }
28
+ function isWindowsDriveAbsolute(value) {
29
+ return /^[A-Za-z]:[\\/]/.test(value);
30
+ }
31
+ /**
32
+ * Lexical hostility checks shared by resolve/assert helpers.
33
+ * Does not leak the candidate path in thrown messages.
34
+ */
35
+ export function rejectHostilePathInput(candidate) {
36
+ if (typeof candidate !== "string" || candidate.length === 0) {
37
+ throw new PathEscapeError("invalid path");
38
+ }
39
+ if (candidate.includes("\0")) {
40
+ throw new PathEscapeError("invalid path");
41
+ }
42
+ const decoded = tryDecodeUri(candidate);
43
+ if (decoded === null) {
44
+ throw new PathEscapeError("invalid path");
45
+ }
46
+ for (const form of decoded === candidate ? [candidate] : [candidate, decoded]) {
47
+ if (form.includes("\0") || hasTraversalSegment(form) || isWindowsDriveAbsolute(form)) {
48
+ throw new PathEscapeError("path escapes repository root");
49
+ }
50
+ }
51
+ }
52
+ function realpathOrSelf(target) {
53
+ try {
54
+ if (fs.existsSync(target)) {
55
+ return fs.realpathSync(target);
56
+ }
57
+ }
58
+ catch {
59
+ // fall through
60
+ }
61
+ return target;
62
+ }
63
+ function realpathRoot(root) {
64
+ try {
65
+ return fs.realpathSync(root);
66
+ }
67
+ catch {
68
+ return path.resolve(root);
69
+ }
70
+ }
71
+ /**
72
+ * Resolve a user-supplied path and require it to remain inside the repository root.
73
+ * Rejects `..`, URI-encoded traversal, null bytes, Windows-drive absolutes, and
74
+ * symlink realpath escapes. Error messages never echo the hostile candidate.
75
+ */
76
+ export function resolveSafeRepoPath(rootInput, candidate) {
77
+ const root = resolveRepoRoot(rootInput);
78
+ rejectHostilePathInput(candidate);
79
+ const decoded = tryDecodeUri(candidate) ?? candidate;
80
+ const primary = decoded;
81
+ const absolute = path.isAbsolute(primary) ? path.normalize(primary) : path.resolve(root, primary);
82
+ // Lexical containment against the resolved (non-realpath) root first.
83
+ // On macOS, /var vs /private/var must not false-reject before realpath.
84
+ if (!isPathInsideRoot(root, absolute) && path.resolve(absolute) !== path.resolve(root)) {
85
+ throw new PathEscapeError("path escapes repository root");
86
+ }
87
+ const realRoot = realpathRoot(root);
88
+ const realTarget = realpathOrSelf(absolute);
89
+ // Symlink / realpath escape check (both sides realpath'd when possible).
90
+ if (!isPathInsideRoot(realRoot, realTarget) && realTarget !== realRoot) {
91
+ throw new PathEscapeError("path escapes repository root");
92
+ }
93
+ return realTarget;
94
+ }
95
+ /** Assert `candidate` resolves inside `root`; throws PathEscapeError otherwise. */
96
+ export function assertInsideRepo(rootInput, candidate) {
97
+ resolveSafeRepoPath(rootInput, candidate);
98
+ }
99
+ /** Resolve safely and return a POSIX path relative to the repo root. */
100
+ export function safeRelPath(rootInput, absoluteOrRel) {
101
+ const abs = resolveSafeRepoPath(rootInput, absoluteOrRel);
102
+ const root = realpathRoot(resolveRepoRoot(rootInput));
103
+ return toPosixRelative(root, abs);
104
+ }
105
+ export { tryDecodeUri as tryDecodeUriComponent };
@@ -0,0 +1,26 @@
1
+ import type { Pool } from "pg";
2
+ import type { StorageProvider } from "../contracts/index.js";
3
+ /**
4
+ * Postgres-backed StorageProvider via the `pg` package.
5
+ * Only usable when a real connection string works — not claimed as always-on enterprise storage.
6
+ */
7
+ export declare class PostgresStorageProvider implements StorageProvider {
8
+ readonly kind: "postgres";
9
+ private readonly pool;
10
+ private ready;
11
+ constructor(pool: Pool);
12
+ private ensureSchema;
13
+ private withClient;
14
+ get(key: string): Promise<string | null>;
15
+ set(key: string, value: string): Promise<void>;
16
+ delete(key: string): Promise<void>;
17
+ list(prefix: string): Promise<string[]>;
18
+ close(): Promise<void>;
19
+ }
20
+ /**
21
+ * Create Postgres storage when a connection string is provided and the server accepts it.
22
+ * Returns `{ error }` on failure — do not claim Postgres is supported without a working URL.
23
+ */
24
+ export declare function tryCreatePostgresStorage(connectionString: string): Promise<PostgresStorageProvider | {
25
+ error: string;
26
+ }>;
@@ -0,0 +1,90 @@
1
+ import pg from "pg";
2
+ const { Pool: PgPool } = pg;
3
+ /**
4
+ * Postgres-backed StorageProvider via the `pg` package.
5
+ * Only usable when a real connection string works — not claimed as always-on enterprise storage.
6
+ */
7
+ export class PostgresStorageProvider {
8
+ kind = "postgres";
9
+ pool;
10
+ ready;
11
+ constructor(pool) {
12
+ this.pool = pool;
13
+ this.ready = this.ensureSchema();
14
+ }
15
+ async ensureSchema() {
16
+ await this.pool.query(`
17
+ CREATE TABLE IF NOT EXISTS agentdoctor_kv (
18
+ key TEXT PRIMARY KEY,
19
+ value TEXT NOT NULL
20
+ );
21
+ `);
22
+ }
23
+ async withClient(fn) {
24
+ await this.ready;
25
+ const client = await this.pool.connect();
26
+ try {
27
+ return await fn(client);
28
+ }
29
+ finally {
30
+ client.release();
31
+ }
32
+ }
33
+ async get(key) {
34
+ return this.withClient(async (client) => {
35
+ const result = await client.query("SELECT value FROM agentdoctor_kv WHERE key = $1", [key]);
36
+ return result.rows[0]?.value ?? null;
37
+ });
38
+ }
39
+ async set(key, value) {
40
+ await this.withClient(async (client) => {
41
+ await client.query(`INSERT INTO agentdoctor_kv (key, value) VALUES ($1, $2)
42
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, [key, value]);
43
+ });
44
+ }
45
+ async delete(key) {
46
+ await this.withClient(async (client) => {
47
+ await client.query("DELETE FROM agentdoctor_kv WHERE key = $1", [key]);
48
+ });
49
+ }
50
+ async list(prefix) {
51
+ return this.withClient(async (client) => {
52
+ const result = await client.query("SELECT key FROM agentdoctor_kv WHERE key LIKE $1 ORDER BY key", [`${prefix}%`]);
53
+ return result.rows.map((r) => r.key);
54
+ });
55
+ }
56
+ async close() {
57
+ await this.pool.end();
58
+ }
59
+ }
60
+ /**
61
+ * Create Postgres storage when a connection string is provided and the server accepts it.
62
+ * Returns `{ error }` on failure — do not claim Postgres is supported without a working URL.
63
+ */
64
+ export async function tryCreatePostgresStorage(connectionString) {
65
+ if (!connectionString || typeof connectionString !== "string" || !connectionString.trim()) {
66
+ return { error: "Postgres connection string is empty" };
67
+ }
68
+ const pool = new PgPool({
69
+ connectionString: connectionString.trim(),
70
+ max: 2,
71
+ idleTimeoutMillis: 5_000,
72
+ connectionTimeoutMillis: 5_000,
73
+ });
74
+ try {
75
+ const client = await pool.connect();
76
+ try {
77
+ await client.query("SELECT 1");
78
+ }
79
+ finally {
80
+ client.release();
81
+ }
82
+ return new PostgresStorageProvider(pool);
83
+ }
84
+ catch (error) {
85
+ await pool.end().catch(() => undefined);
86
+ return {
87
+ error: `Failed to connect to Postgres: ${error instanceof Error ? error.message : String(error)}`,
88
+ };
89
+ }
90
+ }
@@ -18,13 +18,8 @@ export declare class MemoryStorageProvider implements StorageProvider {
18
18
  delete(key: string): Promise<void>;
19
19
  list(prefix: string): Promise<string[]>;
20
20
  }
21
- /**
22
- * SQLite provider — optional. Requires better-sqlite3 when enabled.
23
- * Without the dependency, construction throws a documented error (no fake DB).
24
- */
25
- export declare function tryCreateSqliteStorage(_root: string): Promise<StorageProvider | {
26
- error: string;
27
- }>;
21
+ export { tryCreateSqliteStorage, SqliteStorageProvider } from "./sqlite.js";
22
+ export { tryCreatePostgresStorage, PostgresStorageProvider } from "./postgres.js";
28
23
  export interface WorkspaceRecord {
29
24
  id: string;
30
25
  name: string;
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { resolveRepoRoot, isPathInsideRoot } from "../utils/path.js";
3
+ import { isPathInsideRoot, resolveRepoRoot } from "../utils/path.js";
4
4
  import { atomicWriteTextFile } from "../utils/fs.js";
5
+ import { rejectHostilePathInput, PathEscapeError } from "../security/paths.js";
5
6
  export class FilesystemStorageProvider {
6
7
  kind = "filesystem";
7
8
  root;
@@ -9,13 +10,25 @@ export class FilesystemStorageProvider {
9
10
  this.root = path.join(resolveRepoRoot(rootInput), subdir);
10
11
  }
11
12
  resolveKey(key) {
12
- if (key.includes("\0") || key.split(/[/\\]/).includes("..") || path.isAbsolute(key)) {
13
- throw new Error(`unsafe storage key: ${key}`);
13
+ if (path.isAbsolute(key)) {
14
+ throw new Error("unsafe storage key");
15
+ }
16
+ try {
17
+ // Do not use resolveSafeRepoPath here — it calls resolveRepoRoot() which
18
+ // walks up from the storage subdir and can false-reject valid keys.
19
+ rejectHostilePathInput(key);
20
+ const target = path.resolve(this.root, key);
21
+ if (!isPathInsideRoot(this.root, target)) {
22
+ throw new PathEscapeError("storage path escape");
23
+ }
24
+ return target;
25
+ }
26
+ catch (error) {
27
+ if (error instanceof PathEscapeError) {
28
+ throw new Error("storage path escape");
29
+ }
30
+ throw new Error("unsafe storage key");
14
31
  }
15
- const target = path.resolve(this.root, key);
16
- if (!isPathInsideRoot(this.root, target))
17
- throw new Error("storage path escape");
18
- return target;
19
32
  }
20
33
  async get(key) {
21
34
  try {
@@ -83,15 +96,8 @@ export class MemoryStorageProvider {
83
96
  return [...this.map.keys()].filter((k) => k.startsWith(prefix)).sort();
84
97
  }
85
98
  }
86
- /**
87
- * SQLite provider — optional. Requires better-sqlite3 when enabled.
88
- * Without the dependency, construction throws a documented error (no fake DB).
89
- */
90
- export async function tryCreateSqliteStorage(_root) {
91
- return {
92
- error: "SQLite storage is feature-flagged and requires optional dependency better-sqlite3 (not bundled). Use filesystem provider for local-first default.",
93
- };
94
- }
99
+ export { tryCreateSqliteStorage, SqliteStorageProvider } from "./sqlite.js";
100
+ export { tryCreatePostgresStorage, PostgresStorageProvider } from "./postgres.js";
95
101
  export async function saveWorkspace(storage, workspace) {
96
102
  await storage.set(`workspaces/${workspace.id}.json`, `${JSON.stringify(workspace, null, 2)}\n`);
97
103
  }
@@ -0,0 +1,33 @@
1
+ import type { StorageProvider } from "../contracts/index.js";
2
+ type DatabaseSyncCtor = new (path: string) => {
3
+ exec(sql: string): void;
4
+ prepare(sql: string): {
5
+ get(...params: unknown[]): unknown;
6
+ run(...params: unknown[]): unknown;
7
+ all(...params: unknown[]): unknown[];
8
+ };
9
+ close(): void;
10
+ };
11
+ /**
12
+ * SQLite-backed StorageProvider using Node's built-in `node:sqlite` (DatabaseSync).
13
+ * Available on Node 22+; feature-detected at runtime.
14
+ */
15
+ export declare class SqliteStorageProvider implements StorageProvider {
16
+ readonly kind: "sqlite";
17
+ private readonly db;
18
+ private readonly dbPath;
19
+ constructor(db: InstanceType<DatabaseSyncCtor>, dbPath: string);
20
+ get path(): string;
21
+ get(key: string): Promise<string | null>;
22
+ set(key: string, value: string): Promise<void>;
23
+ delete(key: string): Promise<void>;
24
+ list(prefix: string): Promise<string[]>;
25
+ close(): void;
26
+ }
27
+ /**
28
+ * Create SQLite storage at `.agentdoctor/storage.sqlite` when `node:sqlite` is available.
29
+ */
30
+ export declare function tryCreateSqliteStorage(rootInput: string): Promise<SqliteStorageProvider | {
31
+ error: string;
32
+ }>;
33
+ export {};