@saadnvd1/agent-os 0.3.463 → 0.3.464

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/README.md CHANGED
@@ -947,7 +947,7 @@ it may do on its own, and the lines that always come back to you as asks.
947
947
  - **Decision log:** `note` and the brakes and escalations write to
948
948
  `orchestrator_notes`, and each line shows in its chat.
949
949
  - **Standing rules:** what it must keep knowing ("never merge #111", "briefs
950
- must not name competitors") it keeps with `rule_add` and drops with
950
+ must not name third-party products") it keeps with `rule_add` and drops with
951
951
  `rule_remove`, in `orchestrator_rules`. You see, add, edit and remove them in
952
952
  Settings → Workspaces, each change with your passkey (any process on the
953
953
  machine reaches the route, and a rule of yours is your word to it). A rule restricts or informs; it never approves an
@@ -1273,7 +1273,12 @@ added lines for token-shaped secrets with the merge gate's own rule
1273
1273
  (`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
1274
1274
  `lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
1275
1275
  token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
1276
- whole. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1276
+ whole. It also refuses words that must not land in this public repo
1277
+ (`scripts/hooks/banned-terms.ts`): a short built-in list, plus, when it
1278
+ exists, a private list of regexes kept outside the repo in
1279
+ `~/.config/agent-os/banned-names` (`AGENTOS_BANNED_NAMES` overrides the
1280
+ path), checked against added lines and file names; `scripts/check` runs the
1281
+ same check over every tracked file. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1277
1282
  until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1278
1283
  In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1279
1284
  which also holds the app's first-load JavaScript to a budget
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A workspace's standing rules: what its orchestrator must keep knowing
3
3
  * across fresh starts ("never merge #111", "briefs must not name
4
- * competitors"). The orchestrator writes them with rule_add and rule_remove,
4
+ * third-party products"). The orchestrator writes them with rule_add and rule_remove,
5
5
  * Saad in Settings → Workspaces, and every start reads them into its brief.
6
6
  * They restrict and inform; none can approve anything. A removed rule stays
7
7
  * in the table, marked.
@@ -73,7 +73,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
74
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
75
  rule_add:
76
- 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
76
+ 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
77
77
  rule_remove:
78
78
  "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
79
79
  review:
@@ -1,6 +1,6 @@
1
1
  // A linked machine's sessions are sessions here. One whose folder maps to
2
2
  // a project here sits with that project's sessions; the rest are listed
3
- // under their machine ("homelab · 3"), with the same rows. Client-safe.
3
+ // under their machine ("devbox · 3"), with the same rows. Client-safe.
4
4
  import type { Session } from "@/lib/db/types";
5
5
  import { buildShelves, type ShelfInput, type SidebarRow } from "./shelves";
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.463",
3
+ "version": "0.3.464",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -18,7 +18,6 @@
18
18
  "mcp/",
19
19
  "public/",
20
20
  "scripts/",
21
- "!scripts/competitor-audit/**",
22
21
  "stores/",
23
22
  "styles/",
24
23
  "next.config.ts",
package/scripts/check CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # The full guard, the same run locally and in CI:
3
- # scripts/check typecheck, lint, format, tests
3
+ # scripts/check banned terms, typecheck, lint, format, tests
4
4
  # scripts/check --build ...and a production build, held to its JS budget
5
5
  set -euo pipefail
6
6
  cd "$(dirname "$0")/.."
7
7
 
8
8
  step() { printf '\n▸ %s\n' "$1"; }
9
9
 
10
+ step "terms"; npx tsx scripts/hooks/banned-terms.ts --tree
10
11
  step "typecheck"; npx tsc --noEmit
11
12
  step "lint"; npx eslint . --max-warnings=40
12
13
  step "format"; npx prettier --check . --log-level warn
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { execFileSync, spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { GENERIC_TERMS, loadTerms, matcher, scanTree } from "./banned-terms";
7
+
8
+ const generic = GENERIC_TERMS[0];
9
+
10
+ function tempRepo() {
11
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "banned-terms-"));
12
+ const git = (...args: string[]) =>
13
+ execFileSync("git", args, { cwd: repo, stdio: "pipe" });
14
+ git("init", "-q");
15
+ return { repo, git };
16
+ }
17
+
18
+ describe("loadTerms", () => {
19
+ it("is the generic list alone when the private file is absent", () => {
20
+ const terms = loadTerms(path.join(os.tmpdir(), "no-such-banned-names"));
21
+ expect(terms.map((t) => t.source)).toEqual(GENERIC_TERMS);
22
+ });
23
+
24
+ it("adds the private regexes, skipping blanks and comments", () => {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "banned-names-"));
26
+ const file = path.join(dir, "banned-names");
27
+ fs.writeFileSync(file, "# names\nacme[ -]?widgets\n\n zorp \n");
28
+ try {
29
+ const check = matcher(loadTerms(file));
30
+ expect(check("We copied Acme-Widgets here")).toBe('"Acme-Widgets"');
31
+ expect(check("ZORP")).toBe('"ZORP"');
32
+ expect(check(`the ${generic.toUpperCase()} list`)).not.toBeNull();
33
+ expect(check("nothing to see")).toBeNull();
34
+ } finally {
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("scanTree", () => {
41
+ it("names the file and line of each hit, and a hit in a path", () => {
42
+ const { repo, git } = tempRepo();
43
+ try {
44
+ fs.mkdirSync(path.join(repo, `${generic}-x`));
45
+ fs.writeFileSync(path.join(repo, `${generic}-x`, "a.md"), "fine\n");
46
+ fs.writeFileSync(path.join(repo, "b.ts"), `ok\n// a ${generic}\n`);
47
+ fs.writeFileSync(path.join(repo, "c.bin"), `\0${generic}`);
48
+ git("add", ".");
49
+ expect(scanTree(repo, loadTerms("/nonexistent"))).toEqual([
50
+ { file: "b.ts", line: 2, what: `"${generic}"` },
51
+ { file: `${generic}-x/a.md`, line: 0, what: `"${generic}"` },
52
+ ]);
53
+ } finally {
54
+ fs.rmSync(repo, { recursive: true, force: true });
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("the pre-commit check", () => {
60
+ it("fails on a staged private name, and passes once it's gone", () => {
61
+ const { repo, git } = tempRepo();
62
+ const list = path.join(repo, "..", `${path.basename(repo)}-names`);
63
+ fs.writeFileSync(list, "acme\n");
64
+ const scan = () =>
65
+ spawnSync(
66
+ path.resolve("node_modules/.bin/tsx"),
67
+ [path.resolve("scripts/hooks/banned-terms.ts")],
68
+ {
69
+ cwd: repo,
70
+ encoding: "utf-8",
71
+ env: { ...process.env, AGENTOS_BANNED_NAMES: list },
72
+ }
73
+ );
74
+ try {
75
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike Acme does\n");
76
+ git("add", "x.md");
77
+ const bad = scan();
78
+ expect(bad.status).toBe(1);
79
+ expect(bad.stderr).toContain("x.md:2");
80
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike others do\n");
81
+ git("add", "x.md");
82
+ expect(scan().status).toBe(0);
83
+ fs.writeFileSync(path.join(repo, "acme-notes.md"), "clean\n");
84
+ git("add", "acme-notes.md");
85
+ const named = scan();
86
+ expect(named.status).toBe(1);
87
+ expect(named.stderr).toContain("acme-notes.md:0");
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ fs.rmSync(list, { force: true });
91
+ }
92
+ });
93
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Words that must never land in this public repo: one generic term, always,
3
+ * plus a private denylist kept outside the repo (the list itself would be a
4
+ * mention), one case-insensitive regex per line, skipped when it's absent.
5
+ *
6
+ * tsx scripts/hooks/banned-terms.ts the staged diff's added lines (pre-commit)
7
+ * tsx scripts/hooks/banned-terms.ts --tree every tracked file (scripts/check, CI)
8
+ */
9
+
10
+ import { execFileSync } from "child_process";
11
+ import fs from "fs";
12
+ import os from "os";
13
+ import path from "path";
14
+ import { scanDiff, stagedDiff, type SecretHit } from "./staged-secrets";
15
+
16
+ // Assembled so this file isn't a hit itself.
17
+ export const GENERIC_TERMS = [["compet", "itor"].join("")];
18
+
19
+ export const bannedFile = () =>
20
+ process.env.AGENTOS_BANNED_NAMES ||
21
+ path.join(os.homedir(), ".config", "agent-os", "banned-names");
22
+
23
+ export function loadTerms(file = bannedFile()): RegExp[] {
24
+ const generic = GENERIC_TERMS.map((t) => new RegExp(t, "i"));
25
+ let raw = "";
26
+ try {
27
+ raw = fs.readFileSync(file, "utf8");
28
+ } catch {
29
+ return generic;
30
+ }
31
+ const own = raw
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l && !l.startsWith("#"))
35
+ .map((l) => new RegExp(l, "i"));
36
+ return [...generic, ...own];
37
+ }
38
+
39
+ // What a line matched, without echoing the private pattern that caught it.
40
+ export const matcher = (terms: RegExp[]) => (line: string) => {
41
+ for (const t of terms) {
42
+ const m = line.match(t);
43
+ if (m) return `"${m[0]}"`;
44
+ }
45
+ return null;
46
+ };
47
+
48
+ export function scanTree(cwd: string, terms: RegExp[]): SecretHit[] {
49
+ const check = matcher(terms);
50
+ const files = execFileSync("git", ["ls-files", "-z"], {
51
+ cwd,
52
+ encoding: "utf8",
53
+ maxBuffer: 64 * 1024 * 1024,
54
+ })
55
+ .split("\0")
56
+ .filter(Boolean);
57
+ const hits: SecretHit[] = [];
58
+ for (const file of files) {
59
+ let text: string;
60
+ try {
61
+ text = fs.readFileSync(path.join(cwd, file), "utf8");
62
+ } catch {
63
+ continue; // deleted in the working tree, or a submodule
64
+ }
65
+ if (text.includes("\0")) continue;
66
+ text.split("\n").forEach((l, i) => {
67
+ const what = check(l);
68
+ if (what) hits.push({ file, line: i + 1, what });
69
+ });
70
+ }
71
+ // A path can name something too.
72
+ for (const file of files) {
73
+ const what = check(file);
74
+ if (what) hits.push({ file, line: 0, what });
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ function stagedHits(terms: RegExp[]): SecretHit[] {
80
+ const check = matcher(terms);
81
+ const names = execFileSync(
82
+ "git",
83
+ ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
84
+ { encoding: "utf8" }
85
+ )
86
+ .split("\0")
87
+ .filter(Boolean)
88
+ .flatMap((file) => {
89
+ const what = check(file);
90
+ return what ? [{ file, line: 0, what }] : [];
91
+ });
92
+ return [...scanDiff(stagedDiff(), check), ...names];
93
+ }
94
+
95
+ if (process.argv[1]?.endsWith("banned-terms.ts")) {
96
+ const terms = loadTerms();
97
+ const hits = process.argv.includes("--tree")
98
+ ? scanTree(process.cwd(), terms)
99
+ : stagedHits(terms);
100
+ for (const h of hits)
101
+ console.error(`✗ ${h.file}:${h.line}: names ${h.what}; reword it`);
102
+ if (hits.length) process.exit(1);
103
+ }
@@ -13,10 +13,13 @@ export interface SecretHit {
13
13
  what: string;
14
14
  }
15
15
 
16
- // Each added line of a `git diff -U0` with the file and new line number it
17
- // lands on. Headers are only read before a file's first hunk, so an added
18
- // line that itself starts with "++" isn't taken for one.
19
- export function scanDiff(diff: string): SecretHit[] {
16
+ // Each added line of a `git diff -U0` that `check` flags, with the file and
17
+ // new line number it lands on. Headers are only read before a file's first
18
+ // hunk, so an added line that itself starts with "++" isn't taken for one.
19
+ export function scanDiff(
20
+ diff: string,
21
+ check: (line: string) => string | null = secretsIn
22
+ ): SecretHit[] {
20
23
  const hits: SecretHit[] = [];
21
24
  let file = "";
22
25
  let line = 0;
@@ -30,7 +33,7 @@ export function scanDiff(diff: string): SecretHit[] {
30
33
  header = false;
31
34
  line = Number(l.match(/\+(\d+)/)?.[1] ?? 0);
32
35
  } else if (!header && l.startsWith("+")) {
33
- const what = secretsIn(l.slice(1));
36
+ const what = check(l.slice(1));
34
37
  if (what) hits.push({ file, line, what });
35
38
  line++;
36
39
  }
@@ -38,8 +41,8 @@ export function scanDiff(diff: string): SecretHit[] {
38
41
  return hits;
39
42
  }
40
43
 
41
- if (process.argv[1]?.endsWith("staged-secrets.ts")) {
42
- const diff = execFileSync(
44
+ export const stagedDiff = () =>
45
+ execFileSync(
43
46
  "git",
44
47
  [
45
48
  "diff",
@@ -51,7 +54,9 @@ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
51
54
  ],
52
55
  { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }
53
56
  );
54
- const hits = scanDiff(diff);
57
+
58
+ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
59
+ const hits = scanDiff(stagedDiff());
55
60
  for (const h of hits)
56
61
  console.error(`✗ ${h.file}:${h.line}: looks like a secret (${h.what})`);
57
62
  if (hits.length) {