@forwardimpact/outpost 3.4.0 → 3.5.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.
- package/package.json +2 -4
- package/src/agent-path.js +42 -0
- package/src/agent-runner.js +12 -20
- package/src/outpost.js +1 -1
- package/src/socket-server.js +14 -1
- package/src/spawn-env.js +62 -0
- package/src/state-manager.js +17 -2
- package/templates/.claude/settings.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/outpost",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
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
|
+
}
|
package/src/agent-runner.js
CHANGED
|
@@ -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 =
|
|
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
package/src/socket-server.js
CHANGED
|
@@ -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
|
-
|
|
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"),
|
package/src/spawn-env.js
ADDED
|
@@ -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
|
+
}
|
package/src/state-manager.js
CHANGED
|
@@ -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/**)",
|