@agentmemory/fs-watcher 0.1.0 → 0.2.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 (2) hide show
  1. package/package.json +1 -1
  2. package/watcher.mjs +200 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmemory/fs-watcher",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Filesystem connector for agentmemory — emits observations on file changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/watcher.mjs CHANGED
@@ -1,6 +1,39 @@
1
- import { watch, promises as fsp, statSync } from "node:fs";
2
- import { resolve, relative, join, extname, sep, basename } from "node:path";
1
+ import { watch, promises as fsp, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve, relative, join, extname, sep, basename, dirname } from "node:path";
3
4
  import { randomBytes } from "node:crypto";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ // Same resolution order as the hooks' resolveProject (git repository basename,
8
+ // then directory basename) so a watched subdirectory scopes to the repository
9
+ // name instead of the subdirectory name.
10
+ function deriveProjectName(dir) {
11
+ try {
12
+ const [top, gitDir, commonDir] = execFileSync(
13
+ "git",
14
+ ["rev-parse", "--show-toplevel", "--git-dir", "--git-common-dir"],
15
+ {
16
+ cwd: dir,
17
+ stdio: ["ignore", "pipe", "ignore"],
18
+ encoding: "utf8",
19
+ },
20
+ )
21
+ .trim()
22
+ .split(/\r?\n/);
23
+ if (top) return basename(repositoryRoot(dir, top, gitDir, commonDir));
24
+ } catch {
25
+ // not a git repo
26
+ }
27
+ return basename(dir);
28
+ }
29
+
30
+ function repositoryRoot(dir, top, gitDir, commonDir) {
31
+ if (!gitDir || !commonDir) return top;
32
+ const physical = realpathSync(dir);
33
+ const common = resolve(physical, commonDir);
34
+ if (resolve(physical, gitDir) === common || basename(common) !== ".git") return top;
35
+ return dirname(common);
36
+ }
4
37
 
5
38
  const TEXT_EXTENSIONS = new Set([
6
39
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
@@ -28,15 +61,156 @@ const DEFAULT_IGNORE = [
28
61
 
29
62
  const MAX_PREVIEW_BYTES = 4096;
30
63
  const DEBOUNCE_MS = 500;
64
+ const REDACTED = "[REDACTED]";
65
+ const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
66
+ const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/;
67
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
68
+ const JWT_MIN_LEN = 100;
69
+
70
+ function isDotEnvPath(path) {
71
+ const name = basename(path).toLowerCase();
72
+ return name === ".env" || name.startsWith(".env.");
73
+ }
74
+
75
+ function isSensitiveKey(key) {
76
+ const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
77
+ return [
78
+ "apikey",
79
+ "accesstoken",
80
+ "accesskey",
81
+ "authorization",
82
+ "bearer",
83
+ "clientsecret",
84
+ "password",
85
+ "passwd",
86
+ "privatekey",
87
+ "pwd",
88
+ "secret",
89
+ "token",
90
+ ].some((needle) => normalized.includes(needle));
91
+ }
92
+
93
+ function redactJwtTokens(line) {
94
+ return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match));
95
+ }
96
+
97
+ function redactSensitiveLine(line) {
98
+ if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) {
99
+ return line;
100
+ }
101
+ const assignment = line.match(
102
+ /^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/,
103
+ );
104
+ if (assignment && isSensitiveKey(assignment[2])) {
105
+ const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null;
106
+ return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`;
107
+ }
108
+ const bearerRedacted = line.replace(
109
+ /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi,
110
+ `$1${REDACTED}`,
111
+ );
112
+ return redactJwtTokens(bearerRedacted);
113
+ }
114
+
115
+ function redactPemBlocks(preview) {
116
+ const lines = preview.split("\n");
117
+ const out = [];
118
+ let inBlock = false;
119
+ for (const line of lines) {
120
+ if (!inBlock) {
121
+ const beginMatch = line.match(PEM_BEGIN_RE);
122
+ if (!beginMatch) {
123
+ out.push(line);
124
+ continue;
125
+ }
126
+ const beginIdx = beginMatch.index;
127
+ const endMatch = line.match(PEM_END_RE);
128
+ if (endMatch && endMatch.index > beginIdx) {
129
+ const before = line.slice(0, beginIdx);
130
+ const after = line.slice(endMatch.index + endMatch[0].length);
131
+ out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`);
132
+ } else {
133
+ out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`);
134
+ out.push(REDACTED);
135
+ inBlock = true;
136
+ }
137
+ } else {
138
+ const endMatch = line.match(PEM_END_RE);
139
+ if (endMatch) {
140
+ out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`);
141
+ inBlock = false;
142
+ }
143
+ }
144
+ }
145
+ return out.join("\n");
146
+ }
147
+
148
+ function redactSensitivePreview(preview) {
149
+ return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n");
150
+ }
151
+
152
+ function usableSecret(value) {
153
+ const trimmed = typeof value === "string" ? value.trim() : "";
154
+ if (!trimmed || (trimmed.startsWith("${") && trimmed.endsWith("}"))) return "";
155
+ return trimmed;
156
+ }
157
+
158
+ function readAgentmemoryFile(name) {
159
+ try {
160
+ return readFileSync(join(homedir(), ".agentmemory", name), "utf-8");
161
+ } catch {
162
+ return "";
163
+ }
164
+ }
165
+
166
+ function envFileSecret() {
167
+ let found = "";
168
+ for (const line of readAgentmemoryFile(".env").split("\n")) {
169
+ const trimmed = line.trim();
170
+ if (trimmed.startsWith("#")) continue;
171
+ const eq = trimmed.indexOf("=");
172
+ if (eq === -1) continue;
173
+ if (trimmed.slice(0, eq).replace(/^export\s+/, "").trim() !== "AGENTMEMORY_SECRET") continue;
174
+ let value = trimmed.slice(eq + 1).trim();
175
+ const quote = value[0];
176
+ const close = quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1;
177
+ if (close > 0) value = value.slice(1, close);
178
+ else if (value.includes(" #")) value = value.slice(0, value.indexOf(" #"));
179
+ found = usableSecret(value);
180
+ }
181
+ return found;
182
+ }
183
+
184
+ function isLoopbackUrl(url) {
185
+ try {
186
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
187
+ return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ export function resolveSecret(url, explicit) {
194
+ const configured = usableSecret(explicit);
195
+ if (configured) return configured;
196
+ if (!isLoopbackUrl(url)) return "";
197
+ return envFileSecret() || usableSecret(readAgentmemoryFile("secret"));
198
+ }
31
199
 
32
200
  export class FilesystemWatcher {
33
201
  constructor(config = {}) {
34
202
  this.roots = (config.roots || []).map((r) => resolve(r));
35
203
  this.baseUrl = (config.baseUrl || "http://localhost:3111").replace(/\/+$/, "");
36
- this.secret = config.secret;
204
+ this.secret = resolveSecret(this.baseUrl, config.secret);
37
205
  this.project =
38
206
  config.project ||
39
- (this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher");
207
+ (this.roots[0] ? deriveProjectName(this.roots[0]) : "filesystem-watcher");
208
+ // Per-root scope: a multi-root watcher must stamp each event with the
209
+ // project of the root that produced it, not the first root's project.
210
+ // An explicit config.project overrides for every root.
211
+ this.projectByRoot = new Map(
212
+ this.roots.map((r) => [r, config.project || deriveProjectName(r)]),
213
+ );
40
214
  this.sessionId =
41
215
  config.sessionId ||
42
216
  `fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
@@ -54,7 +228,7 @@ export class FilesystemWatcher {
54
228
  isTextFile(path) {
55
229
  if (this.allowBinary) return true;
56
230
  const ext = extname(path).toLowerCase();
57
- return TEXT_EXTENSIONS.has(ext);
231
+ return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path);
58
232
  }
59
233
 
60
234
  async readPreview(path) {
@@ -121,12 +295,13 @@ export class FilesystemWatcher {
121
295
  let preview = null;
122
296
  if (exists && this.isTextFile(absPath)) {
123
297
  preview = await this.readPreview(absPath);
298
+ if (preview !== null) preview = redactSensitivePreview(preview);
124
299
  }
125
300
  const truncated = exists && size > MAX_PREVIEW_BYTES;
126
301
  const payload = {
127
302
  hookType: "post_tool_use",
128
303
  sessionId: this.sessionId,
129
- project: this.project,
304
+ project: this.projectByRoot.get(rootDir) ?? this.project,
130
305
  cwd: rootDir,
131
306
  timestamp: new Date().toISOString(),
132
307
  data: {
@@ -160,6 +335,18 @@ export class FilesystemWatcher {
160
335
  const failures = [];
161
336
  for (const root of this.roots) {
162
337
  try {
338
+ // Validate the root before handing it to fs.watch: on Linux with
339
+ // Node 24+, fs.watch on a nonexistent path no longer throws
340
+ // synchronously, so a missing root would otherwise count as
341
+ // "attached" and be watched-in-name-only. An explicit stat keeps
342
+ // the failure deterministic across Node versions and platforms.
343
+ const st = statSync(root, { throwIfNoEntry: false });
344
+ if (!st) {
345
+ throw new Error("no such directory");
346
+ }
347
+ if (!st.isDirectory()) {
348
+ throw new Error("not a directory");
349
+ }
163
350
  const handle = watch(
164
351
  root,
165
352
  { recursive: true, persistent: true },
@@ -219,7 +406,13 @@ export function configFromEnv(env = process.env) {
219
406
  roots,
220
407
  baseUrl: env.AGENTMEMORY_URL,
221
408
  secret: env.AGENTMEMORY_SECRET,
222
- project: env.AGENTMEMORY_PROJECT || null,
409
+ // AGENTMEMORY_PROJECT_NAME is the canonical override (matches the hooks);
410
+ // AGENTMEMORY_PROJECT stays as a deprecated alias for existing setups.
411
+ // Trimmed, with whitespace-only treated as unset, same as resolveProject.
412
+ project:
413
+ (env.AGENTMEMORY_PROJECT_NAME || "").trim() ||
414
+ (env.AGENTMEMORY_PROJECT || "").trim() ||
415
+ null,
223
416
  sessionId: env.AGENTMEMORY_SESSION_ID || null,
224
417
  ignorePatterns: extraIgnore,
225
418
  allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1",