@forwardimpact/outpost 3.4.0 → 3.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/outpost",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Personal operations center — context from email, calendar, and knowledge assembled so preparation is continuous, not a morning scramble.",
5
5
  "homepage": "https://www.forwardimpact.team",
6
6
  "repository": {
@@ -45,9 +45,7 @@
45
45
  "scripts": {
46
46
  "start": "bun ./bin/fit-outpost.js",
47
47
  "status": "bun ./bin/fit-outpost.js status",
48
- "build": "bun pkg/build.js",
49
- "build:app": "bun pkg/build.js --app",
50
- "build:pkg": "bun pkg/build.js --pkg"
48
+ "build": "bun pkg/build.js"
51
49
  },
52
50
  "dependencies": {
53
51
  "@forwardimpact/libcli": "^0.1.0",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * agent-path — validate a config-supplied agent name before using it as a
3
+ * filesystem path component.
4
+ *
5
+ * The agent-state writer maps an agent name to a per-agent state filename.
6
+ * A name carrying `/` or `..` segments would let a rewritten `scheduler.json`
7
+ * direct writes outside `~/.cache/fit/outpost/state/`. This module validates
8
+ * and rejects rather than silently sanitising, so an unexpected path segment
9
+ * surfaces as an intrusion signal instead of a quietly rewritten filename.
10
+ */
11
+
12
+ /** Raised when an agent name cannot map to a safe state-file prefix. */
13
+ export class UnsafeAgentNameError extends Error {
14
+ /** @param {string} name */
15
+ constructor(name) {
16
+ super(`unsafe agent name for state path: ${JSON.stringify(name)}`);
17
+ this.name = "UnsafeAgentNameError";
18
+ this.agentName = name;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Map an agent name to a safe state-file prefix (hyphen → underscore).
24
+ * @param {string} name
25
+ * @returns {string} safe filename prefix
26
+ * @throws {UnsafeAgentNameError} when `name` is empty, non-string, or contains
27
+ * `/`, `\`, `..`, NUL, or a leading `~`.
28
+ */
29
+ export function agentNameToStatePrefix(name) {
30
+ if (
31
+ typeof name !== "string" ||
32
+ name.length === 0 ||
33
+ name.includes("/") ||
34
+ name.includes("\\") ||
35
+ name.includes("..") ||
36
+ name.includes("\0") ||
37
+ name.startsWith("~")
38
+ ) {
39
+ throw new UnsafeAgentNameError(String(name));
40
+ }
41
+ return name.replace(/-/g, "_");
42
+ }
@@ -11,6 +11,7 @@ import {
11
11
  loadManifest,
12
12
  draftSkills,
13
13
  } from "./posture.js";
14
+ import { buildSpawnEnv } from "./spawn-env.js";
14
15
 
15
16
  /**
16
17
  * System-prompt directive injected under the `brief` posture. Neutralises any
@@ -167,25 +168,6 @@ export class AgentRunner {
167
168
  return args;
168
169
  }
169
170
 
170
- /**
171
- * Build environment for a child process.
172
- * Merges the current process env with config-level env overrides.
173
- * Expands ~ in values to the user's home directory.
174
- * @param {Record<string, string>} [configEnv]
175
- * @returns {Record<string, string>}
176
- */
177
- #buildSpawnEnv(configEnv) {
178
- const env = { ...this.#proc.env };
179
- if (configEnv) {
180
- const home = homedir();
181
- for (const [key, value] of Object.entries(configEnv)) {
182
- const v = String(value);
183
- env[key] = v.startsWith("~/") ? join(home, v.slice(2)) : v;
184
- }
185
- }
186
- return env;
187
- }
188
-
189
171
  /**
190
172
  * Validate the agent's kb path exists, spawn `claude --agent` with the prompt "Observe and act.", and update agent state to active/idle/failed.
191
173
  * @param {string} agentName
@@ -225,7 +207,16 @@ export class AgentRunner {
225
207
  "Observe and act.",
226
208
  ];
227
209
 
228
- const env = this.#buildSpawnEnv(configEnv);
210
+ const { env, rejections } = buildSpawnEnv(configEnv, this.#proc.env);
211
+ for (const key of rejections) {
212
+ this.#log(
213
+ JSON.stringify({
214
+ event: "outpost.spawn_env.rejected",
215
+ key,
216
+ agent: agentName,
217
+ }),
218
+ );
219
+ }
229
220
  const spawnMod = await this.#resolveSpawn();
230
221
 
231
222
  try {
@@ -257,6 +248,7 @@ export class AgentRunner {
257
248
  stdout,
258
249
  agentName,
259
250
  this.#cacheDir,
251
+ this.#log,
260
252
  );
261
253
  } else {
262
254
  const errMsg = stderr || stdout || `Exit code ${exitCode}`;
package/src/outpost.js CHANGED
@@ -445,7 +445,7 @@ export async function run(runtime, version) {
445
445
  );
446
446
  return 1;
447
447
  }
448
- await agentRunner.wake(args[0], agent, state);
448
+ await agentRunner.wake(args[0], agent, state, config.env);
449
449
  return 0;
450
450
  },
451
451
  init: async () => {
@@ -8,6 +8,7 @@ import { createLogger } from "@forwardimpact/libtelemetry";
8
8
  import { join, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { computeNextWakeAt, nowFromClock } from "./scheduler.js";
11
+ import { agentNameToStatePrefix, UnsafeAgentNameError } from "./agent-path.js";
11
12
 
12
13
  /** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
13
14
  export class SocketServer {
@@ -131,7 +132,19 @@ export class SocketServer {
131
132
  #resolveBriefingFile(agentName, agentConfig) {
132
133
  const stateDir = join(this.#cacheDir, "state");
133
134
  if (this.#fsSync.existsSync(stateDir)) {
134
- const prefix = agentName.replace(/-/g, "_") + "_";
135
+ let prefix;
136
+ try {
137
+ prefix = agentNameToStatePrefix(agentName) + "_";
138
+ } catch (err) {
139
+ if (!(err instanceof UnsafeAgentNameError)) throw err;
140
+ this.#log(
141
+ JSON.stringify({
142
+ event: "outpost.state_path.rejected",
143
+ agent: agentName,
144
+ }),
145
+ );
146
+ return null;
147
+ }
135
148
  const found = this.#latestFileByMtime(
136
149
  stateDir,
137
150
  (f) => f.startsWith(prefix) && f.endsWith(".md"),
@@ -0,0 +1,62 @@
1
+ /**
2
+ * spawn-env — env allow-set and the pure spawn-environment filter.
3
+ *
4
+ * The daemon-mediated wake paths forward `config.env` from
5
+ * `~/.fit/outpost/scheduler.json` into spawned `claude` processes. This module
6
+ * is the single trust contract that decides which keys are honored.
7
+ */
8
+
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ /**
13
+ * Build a Set whose mutators are neutralised. `Object.freeze` alone does not
14
+ * stop `Set.prototype.add`/`delete`/`clear` from mutating internal state, so
15
+ * the allow-set is only a durable trust contract if the mutators themselves
16
+ * throw.
17
+ * @param {string[]} keys
18
+ * @returns {ReadonlySet<string>}
19
+ */
20
+ function frozenSet(keys) {
21
+ const set = new Set(keys);
22
+ for (const m of ["add", "delete", "clear"]) {
23
+ Object.defineProperty(set, m, {
24
+ value: () => {
25
+ throw new TypeError(`AGENT_ENV_ALLOWSET is immutable: ${m}() denied`);
26
+ },
27
+ });
28
+ }
29
+ return Object.freeze(set);
30
+ }
31
+
32
+ /**
33
+ * Env keys the daemon honors for spawned agents. Add new keys here under
34
+ * code review. This single point is the env trust contract.
35
+ * @type {ReadonlySet<string>}
36
+ */
37
+ export const AGENT_ENV_ALLOWSET = frozenSet(["ANTHROPIC_API_KEY"]);
38
+
39
+ /**
40
+ * Build the spawn environment from a base env plus allow-set members of
41
+ * `configEnv`. Keys outside the allow-set are dropped and returned in
42
+ * `rejections`. Tilde-prefixed values are home-expanded. Pure; the caller logs.
43
+ * @param {Record<string,string>=} configEnv
44
+ * @param {NodeJS.ProcessEnv} baseEnv
45
+ * @returns {{ env: Record<string,string>, rejections: string[] }}
46
+ */
47
+ export function buildSpawnEnv(configEnv, baseEnv) {
48
+ const env = { ...baseEnv };
49
+ const rejections = [];
50
+ if (configEnv) {
51
+ const home = homedir();
52
+ for (const [key, value] of Object.entries(configEnv)) {
53
+ if (!AGENT_ENV_ALLOWSET.has(key)) {
54
+ rejections.push(key);
55
+ continue;
56
+ }
57
+ const v = String(value);
58
+ env[key] = v.startsWith("~/") ? join(home, v.slice(2)) : v;
59
+ }
60
+ }
61
+ return { env, rejections };
62
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { dirname, join } from "node:path";
6
6
  import { isoTimestamp } from "@forwardimpact/libutil";
7
+ import { agentNameToStatePrefix, UnsafeAgentNameError } from "./agent-path.js";
7
8
 
8
9
  /** Persist and query agent scheduler state from a JSON file on disk. */
9
10
  export class StateManager {
@@ -92,9 +93,10 @@ export class StateManager {
92
93
  * @param {string} stdout
93
94
  * @param {string} agentName
94
95
  * @param {string} cacheDir - Cache directory for state files
96
+ * @param {Function} [logFn] - Optional logger for rejection records
95
97
  * @returns {Promise<void>}
96
98
  */
97
- async updateAgentState(agentState, stdout, agentName, cacheDir) {
99
+ async updateAgentState(agentState, stdout, agentName, cacheDir, logFn) {
98
100
  const lines = stdout.split("\n");
99
101
  const decisionLine = lines.find((l) => l.startsWith("Decision:"));
100
102
  const actionLine = lines.find((l) => l.startsWith("Action:"));
@@ -113,8 +115,21 @@ export class StateManager {
113
115
 
114
116
  // Save output as briefing fallback
115
117
  const stateDir = join(cacheDir, "state");
118
+ let prefix;
119
+ try {
120
+ prefix = agentNameToStatePrefix(agentName);
121
+ } catch (err) {
122
+ if (!(err instanceof UnsafeAgentNameError)) throw err;
123
+ if (logFn)
124
+ logFn(
125
+ JSON.stringify({
126
+ event: "outpost.state_path.rejected",
127
+ agent: agentName,
128
+ }),
129
+ );
130
+ return;
131
+ }
116
132
  await this.#fs.mkdir(stateDir, { recursive: true });
117
- const prefix = agentName.replace(/-/g, "_");
118
133
  await this.#fs.writeFile(
119
134
  join(stateDir, `${prefix}_last_output.md`),
120
135
  stdout,
@@ -62,6 +62,10 @@
62
62
  "Bash(killall *)",
63
63
  "Bash(launchctl *)",
64
64
  "Bash(brew *)",
65
+ "Edit(~/.fit/outpost/**)",
66
+ "Edit(~/.cache/fit/outpost/state/**)",
67
+ "Bash(sed * ~/.fit/outpost/**)",
68
+ "Bash(sed * ~/.cache/fit/outpost/state/**)",
65
69
  "Edit(~/Library/**)",
66
70
  "Read(~/Pictures/**)",
67
71
  "Read(~/Music/**)",