@saadnvd1/agent-os 0.3.462 → 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 +11 -2
- package/app/api/code-search/available/route.ts +1 -1
- package/app/api/code-search/route.ts +5 -3
- package/app/api/files/upload-temp/route.ts +3 -5
- package/app/api/git/file-content/route.ts +15 -7
- package/app/api/git/pr/route.ts +43 -28
- package/app/api/sessions/[id]/send-keys/route.ts +2 -4
- package/app/api/sessions/[id]/summarize/route.ts +16 -7
- package/app/api/sessions/init-script/route.ts +1 -1
- package/data/code-search/queries.ts +5 -3
- package/lib/code-search.ts +86 -60
- package/lib/db/queries.ts +13 -0
- package/lib/hosts/discover-signature.ts +36 -5
- package/lib/hosts/discover.ts +10 -3
- package/lib/orchestrator/rules.ts +1 -1
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/pr-generation.ts +37 -27
- package/lib/pr.ts +57 -35
- package/lib/sidebar/machines.ts +1 -1
- package/lib/status-detector.ts +7 -0
- 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
|
@@ -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/pr-generation.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { run } from "./tasks/gh";
|
|
2
3
|
import { UNTRUSTED_RULE, untrusted } from "./orchestrator/untrusted";
|
|
3
4
|
|
|
4
5
|
export interface GeneratedPRContent {
|
|
@@ -15,7 +16,7 @@ export async function generatePRContent(
|
|
|
15
16
|
): Promise<GeneratedPRContent> {
|
|
16
17
|
try {
|
|
17
18
|
// Get git context
|
|
18
|
-
const { diff, commits, changedFiles } = getGitContext(
|
|
19
|
+
const { diff, commits, changedFiles } = await getGitContext(
|
|
19
20
|
workingDir,
|
|
20
21
|
baseBranch
|
|
21
22
|
);
|
|
@@ -45,23 +46,17 @@ export async function generatePRContent(
|
|
|
45
46
|
/**
|
|
46
47
|
* Get git context for PR generation
|
|
47
48
|
*/
|
|
48
|
-
function getGitContext(
|
|
49
|
+
async function getGitContext(
|
|
49
50
|
workingDir: string,
|
|
50
51
|
baseBranch: string
|
|
51
|
-
): { diff: string; commits: string[]; changedFiles: string[] } {
|
|
52
|
+
): Promise<{ diff: string; commits: string[]; changedFiles: string[] }> {
|
|
52
53
|
let diff = "";
|
|
53
54
|
let commits: string[] = [];
|
|
54
55
|
let changedFiles: string[] = [];
|
|
55
56
|
|
|
56
57
|
// Names and text here come from the repository; every one is an
|
|
57
58
|
// argument to git, never shell text or an option.
|
|
58
|
-
const git = (args: string[]) =>
|
|
59
|
-
execFileSync("git", args, {
|
|
60
|
-
cwd: workingDir,
|
|
61
|
-
encoding: "utf-8",
|
|
62
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
64
|
-
});
|
|
59
|
+
const git = (args: string[]) => run("git", args, workingDir, 10_000);
|
|
65
60
|
const lines = (out: string) =>
|
|
66
61
|
out
|
|
67
62
|
.split("\n")
|
|
@@ -72,7 +67,7 @@ function getGitContext(
|
|
|
72
67
|
// Try to get the remote base branch reference
|
|
73
68
|
let baseBranchRef = baseBranch;
|
|
74
69
|
try {
|
|
75
|
-
git([
|
|
70
|
+
await git([
|
|
76
71
|
"rev-parse",
|
|
77
72
|
"--verify",
|
|
78
73
|
"--end-of-options",
|
|
@@ -82,7 +77,7 @@ function getGitContext(
|
|
|
82
77
|
} catch {
|
|
83
78
|
// Fall back to local branch
|
|
84
79
|
try {
|
|
85
|
-
git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
|
|
80
|
+
await git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
|
|
86
81
|
} catch {
|
|
87
82
|
// Base branch doesn't exist
|
|
88
83
|
return { diff, commits, changedFiles };
|
|
@@ -91,18 +86,18 @@ function getGitContext(
|
|
|
91
86
|
const range = `${baseBranchRef}...HEAD`;
|
|
92
87
|
|
|
93
88
|
try {
|
|
94
|
-
diff = git(["diff", "--stat", "--end-of-options", range, "--"]);
|
|
89
|
+
diff = await git(["diff", "--stat", "--end-of-options", range, "--"]);
|
|
95
90
|
} catch {}
|
|
96
91
|
|
|
97
92
|
try {
|
|
98
93
|
changedFiles = lines(
|
|
99
|
-
git(["diff", "--name-only", "--end-of-options", range, "--"])
|
|
94
|
+
await git(["diff", "--name-only", "--end-of-options", range, "--"])
|
|
100
95
|
);
|
|
101
96
|
} catch {}
|
|
102
97
|
|
|
103
98
|
try {
|
|
104
99
|
commits = lines(
|
|
105
|
-
git([
|
|
100
|
+
await git([
|
|
106
101
|
"log",
|
|
107
102
|
"--pretty=format:%s",
|
|
108
103
|
"--end-of-options",
|
|
@@ -114,11 +109,11 @@ function getGitContext(
|
|
|
114
109
|
|
|
115
110
|
// Also include uncommitted changes
|
|
116
111
|
try {
|
|
117
|
-
const workingDiff = git(["diff", "--stat"]);
|
|
112
|
+
const workingDiff = await git(["diff", "--stat"]);
|
|
118
113
|
if (workingDiff) {
|
|
119
114
|
diff = diff ? `${diff}\n${workingDiff}` : workingDiff;
|
|
120
115
|
}
|
|
121
|
-
const uncommittedFiles = lines(git(["diff", "--name-only"]));
|
|
116
|
+
const uncommittedFiles = lines(await git(["diff", "--name-only"]));
|
|
122
117
|
changedFiles = [...new Set([...changedFiles, ...uncommittedFiles])];
|
|
123
118
|
} catch {}
|
|
124
119
|
} catch (error) {
|
|
@@ -138,7 +133,7 @@ async function generateWithClaude(
|
|
|
138
133
|
): Promise<GeneratedPRContent | null> {
|
|
139
134
|
// Check if Claude CLI is available
|
|
140
135
|
try {
|
|
141
|
-
|
|
136
|
+
await run("claude", ["--version"], workingDir, 5000);
|
|
142
137
|
} catch {
|
|
143
138
|
return null;
|
|
144
139
|
}
|
|
@@ -148,14 +143,7 @@ async function generateWithClaude(
|
|
|
148
143
|
try {
|
|
149
144
|
// Use claude CLI with --print flag for non-interactive output
|
|
150
145
|
// The prompt goes on stdin: never argv, never a shell.
|
|
151
|
-
const output =
|
|
152
|
-
cwd: workingDir,
|
|
153
|
-
input: prompt,
|
|
154
|
-
encoding: "utf-8",
|
|
155
|
-
timeout: 30000,
|
|
156
|
-
maxBuffer: 1024 * 1024,
|
|
157
|
-
});
|
|
158
|
-
|
|
146
|
+
const output = await claudePrint(workingDir, prompt);
|
|
159
147
|
return parseClaudeResponse(output);
|
|
160
148
|
} catch (error) {
|
|
161
149
|
console.debug("Claude CLI invocation failed", error);
|
|
@@ -163,6 +151,28 @@ async function generateWithClaude(
|
|
|
163
151
|
}
|
|
164
152
|
}
|
|
165
153
|
|
|
154
|
+
// `claude --print` with the prompt on stdin, off the event loop and killed
|
|
155
|
+
// at the ceiling.
|
|
156
|
+
function claudePrint(cwd: string, prompt: string): Promise<string> {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const child = execFile(
|
|
159
|
+
"claude",
|
|
160
|
+
["--print"],
|
|
161
|
+
{
|
|
162
|
+
cwd,
|
|
163
|
+
encoding: "utf-8",
|
|
164
|
+
timeout: 30_000,
|
|
165
|
+
killSignal: "SIGKILL",
|
|
166
|
+
maxBuffer: 1024 * 1024,
|
|
167
|
+
},
|
|
168
|
+
(error, stdout) => (error ? reject(error) : resolve(stdout))
|
|
169
|
+
);
|
|
170
|
+
// claude exiting before it read the prompt is its failure, not a crash.
|
|
171
|
+
child.stdin?.on("error", () => {});
|
|
172
|
+
child.stdin?.end(prompt);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
166
176
|
/**
|
|
167
177
|
* Build prompt for PR generation
|
|
168
178
|
*/
|
package/lib/pr.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
|
+
import { run } from "./tasks/gh";
|
|
3
|
+
import { GhBackoffError, isRateLimit } from "./tasks/gh-limit";
|
|
2
4
|
|
|
3
5
|
/** The remote's default branch, or "main" when it has none. */
|
|
4
6
|
export function remoteDefaultBranch(workingDir: string): string {
|
|
@@ -32,14 +34,19 @@ export interface CommitInfo {
|
|
|
32
34
|
body: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// Every call here waits on git, gh or the network: all of it runs off the
|
|
38
|
+
// event loop, so a slow GitHub never stalls the server's other requests.
|
|
39
|
+
|
|
35
40
|
/**
|
|
36
|
-
* Check if gh CLI is installed and authenticated
|
|
41
|
+
* Check if gh CLI is installed and authenticated. Throws GhBackoffError while
|
|
42
|
+
* every gh call waits out GitHub's rate limit: that isn't a missing login.
|
|
37
43
|
*/
|
|
38
|
-
export function checkGhCli(): boolean {
|
|
44
|
+
export async function checkGhCli(): Promise<boolean> {
|
|
39
45
|
try {
|
|
40
|
-
|
|
46
|
+
await run("gh", ["auth", "status"], process.cwd(), 5000);
|
|
41
47
|
return true;
|
|
42
|
-
} catch {
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof GhBackoffError) throw error;
|
|
43
50
|
return false;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
@@ -47,22 +54,25 @@ export function checkGhCli(): boolean {
|
|
|
47
54
|
/**
|
|
48
55
|
* Get commits between current branch and base branch
|
|
49
56
|
*/
|
|
50
|
-
export function getCommitsSinceBase(
|
|
57
|
+
export async function getCommitsSinceBase(
|
|
51
58
|
workingDir: string,
|
|
52
59
|
baseBranch = "main"
|
|
53
|
-
): CommitInfo[] {
|
|
60
|
+
): Promise<CommitInfo[]> {
|
|
54
61
|
try {
|
|
55
62
|
// Get the merge base
|
|
56
63
|
// Names come from the repository (its remote's default branch): they
|
|
57
64
|
// are arguments, never shell text or options.
|
|
58
|
-
const mergeBase =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
const mergeBase = (
|
|
66
|
+
await run(
|
|
67
|
+
"git",
|
|
68
|
+
["merge-base", "--end-of-options", baseBranch, "HEAD"],
|
|
69
|
+
workingDir,
|
|
70
|
+
10_000
|
|
71
|
+
)
|
|
62
72
|
).trim();
|
|
63
73
|
|
|
64
74
|
// Get commits since merge base
|
|
65
|
-
const output =
|
|
75
|
+
const output = await run(
|
|
66
76
|
"git",
|
|
67
77
|
[
|
|
68
78
|
"log",
|
|
@@ -70,7 +80,8 @@ export function getCommitsSinceBase(
|
|
|
70
80
|
"--format=COMMIT_START%n%H%n%s%n%b%nCOMMIT_END",
|
|
71
81
|
"--",
|
|
72
82
|
],
|
|
73
|
-
|
|
83
|
+
workingDir,
|
|
84
|
+
10_000
|
|
74
85
|
);
|
|
75
86
|
|
|
76
87
|
const commits: CommitInfo[] = [];
|
|
@@ -160,12 +171,12 @@ export function generatePRBody(commits: CommitInfo[]): string {
|
|
|
160
171
|
/**
|
|
161
172
|
* Get PR for a branch
|
|
162
173
|
*/
|
|
163
|
-
export function getPRForBranch(
|
|
174
|
+
export async function getPRForBranch(
|
|
164
175
|
workingDir: string,
|
|
165
176
|
branchName: string
|
|
166
|
-
): PRInfo | null {
|
|
177
|
+
): Promise<PRInfo | null> {
|
|
167
178
|
try {
|
|
168
|
-
const output =
|
|
179
|
+
const output = await run(
|
|
169
180
|
"gh",
|
|
170
181
|
[
|
|
171
182
|
"pr",
|
|
@@ -177,11 +188,16 @@ export function getPRForBranch(
|
|
|
177
188
|
"--limit",
|
|
178
189
|
"1",
|
|
179
190
|
],
|
|
180
|
-
|
|
191
|
+
workingDir,
|
|
192
|
+
10_000
|
|
181
193
|
);
|
|
182
194
|
const prs = JSON.parse(output);
|
|
183
195
|
return prs.length > 0 ? prs[0] : null;
|
|
184
|
-
} catch {
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// GitHub's rate limit says nothing about whether a PR exists.
|
|
198
|
+
if (error instanceof GhBackoffError) throw error;
|
|
199
|
+
if (isRateLimit(error))
|
|
200
|
+
throw new GhBackoffError("GitHub's rate limit is spent; try again later");
|
|
185
201
|
return null;
|
|
186
202
|
}
|
|
187
203
|
}
|
|
@@ -189,30 +205,32 @@ export function getPRForBranch(
|
|
|
189
205
|
/**
|
|
190
206
|
* Create a new PR
|
|
191
207
|
*/
|
|
192
|
-
export function createPR(
|
|
208
|
+
export async function createPR(
|
|
193
209
|
workingDir: string,
|
|
194
210
|
branchName: string,
|
|
195
211
|
baseBranch: string,
|
|
196
212
|
title: string,
|
|
197
213
|
body: string
|
|
198
|
-
): PRInfo {
|
|
214
|
+
): Promise<PRInfo> {
|
|
199
215
|
// First ensure branch is pushed
|
|
200
216
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
217
|
+
await run(
|
|
218
|
+
"git",
|
|
219
|
+
["push", "-u", "origin", "--", branchName],
|
|
220
|
+
workingDir,
|
|
221
|
+
30_000
|
|
222
|
+
);
|
|
206
223
|
} catch {
|
|
207
224
|
// Branch might already be pushed
|
|
208
225
|
}
|
|
209
226
|
|
|
210
227
|
// Create PR using gh CLI
|
|
211
228
|
// gh pr create outputs the PR URL on success
|
|
212
|
-
const output =
|
|
229
|
+
const output = await run(
|
|
213
230
|
"gh",
|
|
214
231
|
["pr", "create", "--title", title, "--base", baseBranch, "--body", body],
|
|
215
|
-
|
|
232
|
+
workingDir,
|
|
233
|
+
30_000
|
|
216
234
|
);
|
|
217
235
|
|
|
218
236
|
// Parse URL from output (gh pr create prints the URL)
|
|
@@ -235,20 +253,24 @@ export function createPR(
|
|
|
235
253
|
/**
|
|
236
254
|
* Get current branch name
|
|
237
255
|
*/
|
|
238
|
-
export function getCurrentBranch(workingDir: string): string {
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}).trim();
|
|
256
|
+
export async function getCurrentBranch(workingDir: string): Promise<string> {
|
|
257
|
+
return (
|
|
258
|
+
await run("git", ["branch", "--show-current"], workingDir, 10_000)
|
|
259
|
+
).trim();
|
|
243
260
|
}
|
|
244
261
|
|
|
245
262
|
/**
|
|
246
|
-
* Get the default base branch
|
|
263
|
+
* Get the default base branch: the remote's, or "main" when it has none.
|
|
247
264
|
*/
|
|
248
|
-
export function getBaseBranch(workingDir: string): string {
|
|
265
|
+
export async function getBaseBranch(workingDir: string): Promise<string> {
|
|
249
266
|
try {
|
|
250
|
-
|
|
251
|
-
|
|
267
|
+
const ref = await run(
|
|
268
|
+
"git",
|
|
269
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
270
|
+
workingDir,
|
|
271
|
+
10_000
|
|
272
|
+
);
|
|
273
|
+
return ref.trim().replace(/^origin\//, "");
|
|
252
274
|
} catch {
|
|
253
275
|
return "main";
|
|
254
276
|
}
|
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/lib/status-detector.ts
CHANGED
|
@@ -662,6 +662,13 @@ class SessionStatusDetector {
|
|
|
662
662
|
return [...this.cache.data.values()];
|
|
663
663
|
}
|
|
664
664
|
|
|
665
|
+
// A new object whenever the listing or its errors change (the cache is
|
|
666
|
+
// replaced, never edited): readers derived from them can skip the work
|
|
667
|
+
// while it's the same one.
|
|
668
|
+
listingStamp(): object {
|
|
669
|
+
return this.cache;
|
|
670
|
+
}
|
|
671
|
+
|
|
665
672
|
hostErrors(): Record<string, string> {
|
|
666
673
|
return Object.fromEntries(this.cache.hostErrors);
|
|
667
674
|
}
|
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) {
|