@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 +7 -2
- package/lib/orchestrator/rules.ts +1 -1
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/sidebar/machines.ts +1 -1
- package/package.json +1 -2
- package/scripts/check +2 -1
- package/scripts/hooks/banned-terms.test.ts +93 -0
- package/scripts/hooks/banned-terms.ts +103 -0
- package/scripts/hooks/staged-secrets.ts +13 -8
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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:
|
package/lib/sidebar/machines.ts
CHANGED
|
@@ -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 ("
|
|
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.
|
|
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
|
|
17
|
-
// lands on. Headers are only read before a file's first
|
|
18
|
-
// line that itself starts with "++" isn't taken for one.
|
|
19
|
-
export function scanDiff(
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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) {
|