@forwardimpact/outpost 3.8.1 → 3.9.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.
@@ -1,32 +1,38 @@
1
1
  {
2
2
  "agents": {
3
3
  "postman": {
4
- "kb": "~/Documents/Personal",
4
+ "kb": "~/.local/share/fit/outpost/Team",
5
+ "privilege": "full",
5
6
  "schedule": { "type": "cron", "expression": "*/15 8-18 * * 1-5" },
6
7
  "enabled": true
7
8
  },
8
9
  "concierge": {
9
- "kb": "~/Documents/Personal",
10
+ "kb": "~/.local/share/fit/outpost/Team",
11
+ "privilege": "full",
10
12
  "schedule": { "type": "cron", "expression": "*/30 8-18 * * 1-5" },
11
13
  "enabled": true
12
14
  },
13
15
  "librarian": {
14
- "kb": "~/Documents/Personal",
16
+ "kb": "~/.local/share/fit/outpost/Team",
17
+ "privilege": "restricted",
15
18
  "schedule": { "type": "cron", "expression": "0 9,12,15,18 * * 1-5" },
16
19
  "enabled": true
17
20
  },
18
21
  "chief-of-staff": {
19
- "kb": "~/Documents/Personal",
22
+ "kb": "~/.local/share/fit/outpost/Team",
23
+ "privilege": "restricted",
20
24
  "schedule": { "type": "cron", "expression": "0 7,18 * * 1-5" },
21
25
  "enabled": true
22
26
  },
23
27
  "recruiter": {
24
- "kb": "~/Documents/Personal",
28
+ "kb": "~/.local/share/fit/outpost/Team",
29
+ "privilege": "restricted",
25
30
  "schedule": { "type": "cron", "expression": "0 8,12,17 * * 1-5" },
26
31
  "enabled": true
27
32
  },
28
33
  "head-hunter": {
29
- "kb": "~/Documents/Personal",
34
+ "kb": "~/.local/share/fit/outpost/Team",
35
+ "privilege": "restricted",
30
36
  "schedule": { "type": "cron", "expression": "0 9 * * 1-5" },
31
37
  "enabled": true
32
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/outpost",
3
- "version": "3.8.1",
3
+ "version": "3.9.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": {
@@ -11,6 +11,7 @@ import {
11
11
  loadManifest,
12
12
  draftSkills,
13
13
  } from "./posture.js";
14
+ import { resolvePrivilege, disclaimFor } from "./privilege.js";
14
15
  import { buildSpawnEnv } from "./spawn-env.js";
15
16
 
16
17
  /**
@@ -176,6 +177,24 @@ export class AgentRunner {
176
177
  * @param {Record<string, string>} [configEnv] - Extra env vars from config
177
178
  */
178
179
  async wake(agentName, agent, state, configEnv) {
180
+ // Resolve the mandatory privilege level before any work. A missing or
181
+ // invalid level is fail-closed: log and skip the wake — no agent process is
182
+ // spawned with a guessed privilege. The level lives in the user-only trust
183
+ // root, so a spawned agent cannot raise its own.
184
+ let level;
185
+ try {
186
+ level = resolvePrivilege(agent);
187
+ } catch (err) {
188
+ this.#log(
189
+ JSON.stringify({
190
+ event: "outpost.privilege.rejected",
191
+ agent: agentName,
192
+ error: err.message,
193
+ }),
194
+ );
195
+ return;
196
+ }
197
+
179
198
  if (!agent.kb) {
180
199
  this.#log(`Agent ${agentName}: no "kb" specified, skipping.`);
181
200
  return;
@@ -191,6 +210,13 @@ export class AgentRunner {
191
210
  const claude = await this.#findClaude();
192
211
 
193
212
  this.#log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
213
+ this.#log(
214
+ JSON.stringify({
215
+ event: "outpost.privilege.resolved",
216
+ agent: agentName,
217
+ level,
218
+ }),
219
+ );
194
220
 
195
221
  const as = (state.agents[agentName] ||= {});
196
222
  as.status = "active";
@@ -226,6 +252,7 @@ export class AgentRunner {
226
252
  env,
227
253
  kbPath,
228
254
  this.#runtime,
255
+ disclaimFor(level),
229
256
  );
230
257
  this.#activeChildren.add(pid);
231
258
 
package/src/kb-manager.js CHANGED
@@ -23,6 +23,33 @@ export class KBManager {
23
23
  this.#logger = createLogger("outpost", runtime);
24
24
  }
25
25
 
26
+ /**
27
+ * Resolve a knowledge-base name to its path under the XDG data home,
28
+ * `~/.local/share/fit/outpost/<name>`. The argument is a single path segment,
29
+ * never an arbitrary filesystem path, so a provisioned KB always lands outside
30
+ * TCC-protected folders. The name is validated — not sanitised — against the
31
+ * same rule as `agent-path.js`: a name carrying `/`, `\`, `..`, NUL, or a
32
+ * leading `~` could steer the KB back inside `~/Documents`, reopening the TCC
33
+ * hole, so it is rejected rather than rewritten.
34
+ * @param {string} name - The KB name (e.g. `Team`, `personal`).
35
+ * @returns {string} Absolute path under the data home.
36
+ * @throws {Error} when `name` is empty, non-string, or an unsafe segment.
37
+ */
38
+ static kbPathForName(name) {
39
+ if (
40
+ typeof name !== "string" ||
41
+ name.length === 0 ||
42
+ name.includes("/") ||
43
+ name.includes("\\") ||
44
+ name.includes("..") ||
45
+ name.includes("\0") ||
46
+ name.startsWith("~")
47
+ ) {
48
+ throw new Error(`unsafe KB name: ${JSON.stringify(name)}`);
49
+ }
50
+ return join(homedir(), ".local/share/fit/outpost", name);
51
+ }
52
+
26
53
  /**
27
54
  * Test whether a path exists, via the async fs surface.
28
55
  * @param {string} p
package/src/outpost.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // fit-outpost Wake due agents once and exit
5
5
  // fit-outpost daemon Run continuously (poll every 60s)
6
6
  // fit-outpost wake <agent> Wake a specific agent immediately
7
- // fit-outpost init <path> Initialize a new knowledge base
7
+ // fit-outpost init [name] Initialize a knowledge base by name (default: Team)
8
8
  // fit-outpost update [path] Update KB with latest CLAUDE.md, agents and skills (defaults to current directory)
9
9
  // fit-outpost stop Gracefully stop daemon and all running agents
10
10
  // fit-outpost validate Validate agent definitions exist
@@ -58,7 +58,7 @@ function buildDefinition(version) {
58
58
  },
59
59
  {
60
60
  name: "init",
61
- args: "<path>",
61
+ args: "[name]",
62
62
  description: "Initialize a new knowledge base",
63
63
  },
64
64
  {
@@ -433,13 +433,20 @@ export async function run(runtime, version) {
433
433
  return 1;
434
434
  },
435
435
  init: async () => {
436
- if (!args[0]) {
437
- cli.usageError("missing required argument <path>");
436
+ // `init [name]` provisions a KB by name under the data home (default
437
+ // `Team`), never an arbitrary path — so the substrate cannot be
438
+ // steered back into a TCC-protected folder. An unsafe name is refused.
439
+ const name = args[0] ?? "Team";
440
+ let target;
441
+ try {
442
+ target = KBManager.kbPathForName(name);
443
+ } catch {
444
+ cli.usageError(`invalid KB name "${name}"`);
438
445
  return 2;
439
446
  }
440
447
  const tpl = await requireTemplateDir();
441
448
  if (tpl === null) return 1;
442
- const result = await kbManager.init(args[0], tpl);
449
+ const result = await kbManager.init(target, tpl);
443
450
  if (!result.ok) {
444
451
  proc.stderr.write(result.error + "\n");
445
452
  return result.code;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Privilege — resolve an agent's least-privilege execution level and map it to
3
+ * the hop-2 spawn disclaim flag.
4
+ *
5
+ * The level governs the macOS reach the daemon grants a woken agent. `full`
6
+ * keeps today's single-grant model (the child inherits `fit-outpost.app` as its
7
+ * responsible process, so Full Disk Access and Automation flow to it); a
8
+ * `restricted` agent is held responsible for itself, so those grants are not
9
+ * extended and it can reach only non-TCC-protected substrate.
10
+ *
11
+ * The level is mandatory and lives in the same user-only trust root as the
12
+ * spawn-env allow-set and the state roots, so an agent cannot raise its own
13
+ * level. Patterned on `posture.js` but with no `effective*` coercion and no
14
+ * default — a missing or unrecognised value throws.
15
+ */
16
+
17
+ /** The two privilege levels, in declaration order. */
18
+ export const PRIVILEGE_LEVELS = ["full", "restricted"];
19
+
20
+ /**
21
+ * Resolve an agent's declared privilege level. The level is mandatory: a
22
+ * missing or unrecognised value throws — there is no default.
23
+ * @param {{ privilege?: string }} agent - One agent's config.
24
+ * @returns {"full"|"restricted"} The declared level.
25
+ * @throws {Error} when `agent.privilege` is not one of {@link PRIVILEGE_LEVELS}.
26
+ */
27
+ export function resolvePrivilege(agent) {
28
+ const level = agent?.privilege;
29
+ if (!PRIVILEGE_LEVELS.includes(level)) {
30
+ throw new Error(
31
+ `invalid privilege "${level}"; expected one of ${PRIVILEGE_LEVELS.join(", ")}`,
32
+ );
33
+ }
34
+ return level;
35
+ }
36
+
37
+ /**
38
+ * Map a level to the hop-2 disclaim flag: `restricted` self-disclaims (`1`),
39
+ * `full` keeps the inherited responsible process (`0`).
40
+ * @param {"full"|"restricted"} level
41
+ * @returns {0|1}
42
+ */
43
+ export function disclaimFor(level) {
44
+ return level === "restricted" ? 1 : 0;
45
+ }