@sweny-ai/core 0.1.169 → 0.1.171
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/dist/agent-env.d.ts +2 -1
- package/dist/agent-env.js +9 -3
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -0
- package/dist/budget.js +5 -1
- package/dist/cli/comment-output.d.ts +4 -2
- package/dist/cli/comment-output.js +16 -8
- package/dist/cli/main.js +42 -26
- package/dist/cli/new.js +3 -0
- package/dist/cli/output.d.ts +3 -2
- package/dist/cli/output.js +39 -37
- package/dist/cli/progress.d.ts +33 -0
- package/dist/cli/progress.js +69 -0
- package/dist/cli/renderer.d.ts +4 -0
- package/dist/cli/renderer.js +20 -13
- package/dist/cli/run-output.d.ts +13 -6
- package/dist/cli/run-output.js +36 -26
- package/dist/cli/runs.js +17 -11
- package/dist/cli/style.d.ts +26 -0
- package/dist/cli/style.js +48 -0
- package/dist/cli/terminal.d.ts +47 -0
- package/dist/cli/terminal.js +123 -0
- package/dist/cli/ticket.d.ts +89 -0
- package/dist/cli/ticket.js +218 -0
- package/dist/cli/try.d.ts +15 -8
- package/dist/cli/try.js +84 -65
- package/dist/executor.d.ts +8 -1
- package/dist/executor.js +21 -2
- package/dist/git-credentials.d.ts +106 -0
- package/dist/git-credentials.js +322 -0
- package/dist/harness/acp.js +3 -0
- package/dist/harness/claude-code.d.ts +7 -0
- package/dist/harness/claude-code.js +44 -3
- package/dist/harness/codex.js +3 -0
- package/dist/harness/pi.js +3 -0
- package/dist/harness/policy.d.ts +5 -0
- package/dist/harness/policy.js +14 -0
- package/dist/harness/sandbox-wrapper.d.ts +9 -2
- package/dist/harness/sandbox-wrapper.js +14 -4
- package/dist/harness/types.d.ts +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mermaid.d.ts +10 -5
- package/dist/mermaid.js +11 -13
- package/dist/safe-outputs.js +5 -0
- package/dist/skills/git-push.d.ts +62 -0
- package/dist/skills/git-push.js +132 -0
- package/dist/skills/github.d.ts +17 -0
- package/dist/skills/github.js +23 -0
- package/dist/testing.d.ts +16 -12
- package/dist/testing.js +45 -20
- package/dist/theme.d.ts +185 -0
- package/dist/theme.js +183 -0
- package/dist/types.d.ts +2 -0
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +2 -1
- package/dist/workflows/triage.yml +2 -1
- package/package.json +6 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted git credentials in the checkout (#473).
|
|
3
|
+
*
|
|
4
|
+
* `actions/checkout` keeps the job token on disk by default
|
|
5
|
+
* (`persist-credentials: true`): v4 and v5 write it into `.git/config` as an
|
|
6
|
+
* `http.<origin>/.extraheader`; v6 and later write it to a separate file under
|
|
7
|
+
* `$RUNNER_TEMP` that `.git/config` pulls in with `includeIf.gitdir:...path`.
|
|
8
|
+
* Either way, any agent that can read the repo can read a token that pushes to
|
|
9
|
+
* it. Env scoping cannot reach a file.
|
|
10
|
+
*
|
|
11
|
+
* {@link scanGitCredentials} finds the files that hold such a credential, so a
|
|
12
|
+
* harness can make them unreadable to the agent of a read-only or staged node
|
|
13
|
+
* (the process wrapper's `denyRead`, Claude Code's `Read(...)` deny rules and
|
|
14
|
+
* SDK sandbox `denyRead`), or report the node `degraded` (refused under strict)
|
|
15
|
+
* where it cannot. The fix that needs no masking at all is
|
|
16
|
+
* `persist-credentials: false`: sweny pushes the PR branch itself
|
|
17
|
+
* (`github_create_pr`), with the skill's own token, in the sweny process.
|
|
18
|
+
*
|
|
19
|
+
* What counts as a credential:
|
|
20
|
+
* - `http.extraheader` / `http.<url>.extraheader` (the checkout's header);
|
|
21
|
+
* - `credential.helper` with an inline secret (`!f() { echo password=...; }; f`);
|
|
22
|
+
* a `store` helper's file (default `~/.git-credentials`, or `--file <path>`);
|
|
23
|
+
* - `url.<base>.insteadOf` / `pushInsteadOf` whose base carries userinfo with
|
|
24
|
+
* a password or a token, and `remote.<name>.url` / `pushurl` that do.
|
|
25
|
+
*
|
|
26
|
+
* Files scanned: the repo config (`<commondir>/config`), the worktree config
|
|
27
|
+
* (`<gitdir>/config.worktree`), the global config (`GIT_CONFIG_GLOBAL`, else
|
|
28
|
+
* `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`), and every file they
|
|
29
|
+
* include (`include.path`, and `includeIf.*.path` whatever the condition).
|
|
30
|
+
* Values never leave this module: findings carry the file and a redacted key.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
export { gitCredentialGap } from "./harness/policy.js";
|
|
36
|
+
/** Tokens git hosts mint (GitHub classic and fine-grained, GitLab), and the names checkout-style auth uses. */
|
|
37
|
+
const TOKEN_PATTERN = /(gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|glpat-[A-Za-z0-9_-]{16,}|x-access-token|x-oauth-basic|oauth2:)/i;
|
|
38
|
+
const MAX_INCLUDE_DEPTH = 10;
|
|
39
|
+
/** Does a URL carry a credential in its userinfo (`https://user:pass@`, `https://<token>@`)? */
|
|
40
|
+
export function urlHasCredential(url) {
|
|
41
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)@/i.exec(url.trim());
|
|
42
|
+
if (!m)
|
|
43
|
+
return false;
|
|
44
|
+
const userinfo = m[1];
|
|
45
|
+
if (userinfo.includes(":"))
|
|
46
|
+
return true;
|
|
47
|
+
return TOKEN_PATTERN.test(userinfo);
|
|
48
|
+
}
|
|
49
|
+
/** Does a `credential.helper` value hold the secret itself (an inline shell helper)? */
|
|
50
|
+
export function helperHasInlineSecret(value) {
|
|
51
|
+
const v = value.trim();
|
|
52
|
+
if (!v.startsWith("!"))
|
|
53
|
+
return false;
|
|
54
|
+
return /password=/i.test(v) || TOKEN_PATTERN.test(v);
|
|
55
|
+
}
|
|
56
|
+
/** The file a `store` credential helper writes, or undefined when the helper is not `store`. */
|
|
57
|
+
export function storeHelperFiles(value, env) {
|
|
58
|
+
const parts = value.trim().split(/\s+/);
|
|
59
|
+
const name = parts[0];
|
|
60
|
+
if (name !== "store" && name !== "git-credential-store")
|
|
61
|
+
return undefined;
|
|
62
|
+
for (let i = 1; i < parts.length; i++) {
|
|
63
|
+
const p = parts[i];
|
|
64
|
+
if (p.startsWith("--file="))
|
|
65
|
+
return [expandHome(p.slice("--file=".length), env)];
|
|
66
|
+
if (p === "--file" && parts[i + 1])
|
|
67
|
+
return [expandHome(parts[i + 1], env)];
|
|
68
|
+
}
|
|
69
|
+
const home = env.HOME ?? homedir();
|
|
70
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
71
|
+
return [path.join(home, ".git-credentials"), path.join(xdg, "git", "credentials")];
|
|
72
|
+
}
|
|
73
|
+
function expandHome(p, env) {
|
|
74
|
+
const unquoted = p.replace(/^["']|["']$/g, "");
|
|
75
|
+
if (unquoted === "~")
|
|
76
|
+
return env.HOME ?? homedir();
|
|
77
|
+
if (unquoted.startsWith("~/"))
|
|
78
|
+
return path.join(env.HOME ?? homedir(), unquoted.slice(2));
|
|
79
|
+
return unquoted;
|
|
80
|
+
}
|
|
81
|
+
function realOrSelf(p) {
|
|
82
|
+
try {
|
|
83
|
+
return realpathSync(p);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return p;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Minimal git-config parser: `[section]`, `[section "sub"]`, the legacy
|
|
91
|
+
* `[section.sub]`, `key = value`, bare `key` (boolean), quoted values with
|
|
92
|
+
* escapes, `#` and `;` comments, and `\` line continuations. Section and key
|
|
93
|
+
* names are lowercased; subsections keep their case. Enough to find keys; not
|
|
94
|
+
* a validator.
|
|
95
|
+
*/
|
|
96
|
+
export function parseGitConfig(text) {
|
|
97
|
+
const out = [];
|
|
98
|
+
let section = "";
|
|
99
|
+
let subsection;
|
|
100
|
+
const lines = text.split(/\r?\n/);
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
let line = lines[i];
|
|
103
|
+
// Join continuation lines (a trailing unescaped backslash).
|
|
104
|
+
while (/(^|[^\\])(\\\\)*\\$/.test(line) && i + 1 < lines.length) {
|
|
105
|
+
line = line.slice(0, -1) + lines[++i];
|
|
106
|
+
}
|
|
107
|
+
let rest = line.trim();
|
|
108
|
+
if (rest === "" || rest.startsWith("#") || rest.startsWith(";"))
|
|
109
|
+
continue;
|
|
110
|
+
if (rest.startsWith("[")) {
|
|
111
|
+
const close = rest.indexOf("]");
|
|
112
|
+
if (close === -1)
|
|
113
|
+
continue;
|
|
114
|
+
const header = rest.slice(1, close).trim();
|
|
115
|
+
const q = /^([A-Za-z0-9.-]+)\s+"((?:[^"\\]|\\.)*)"$/.exec(header);
|
|
116
|
+
if (q) {
|
|
117
|
+
section = q[1].toLowerCase();
|
|
118
|
+
subsection = q[2].replace(/\\(.)/g, "$1");
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const dot = header.indexOf(".");
|
|
122
|
+
section = (dot === -1 ? header : header.slice(0, dot)).toLowerCase();
|
|
123
|
+
subsection = dot === -1 ? undefined : header.slice(dot + 1).toLowerCase();
|
|
124
|
+
}
|
|
125
|
+
rest = rest.slice(close + 1).trim();
|
|
126
|
+
if (rest === "" || rest.startsWith("#") || rest.startsWith(";"))
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!section)
|
|
130
|
+
continue;
|
|
131
|
+
const m = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=(.*))?$/.exec(rest);
|
|
132
|
+
if (!m)
|
|
133
|
+
continue;
|
|
134
|
+
out.push({ section, subsection, key: m[1].toLowerCase(), value: m[2] === undefined ? "true" : unquote(m[2]) });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** A git-config value: strip comments outside quotes, drop the quotes, resolve escapes. */
|
|
139
|
+
function unquote(raw) {
|
|
140
|
+
let out = "";
|
|
141
|
+
let quoted = false;
|
|
142
|
+
for (let i = 0; i < raw.length; i++) {
|
|
143
|
+
const c = raw[i];
|
|
144
|
+
if (c === "\\" && i + 1 < raw.length) {
|
|
145
|
+
const n = raw[++i];
|
|
146
|
+
out += n === "n" ? "\n" : n === "t" ? "\t" : n;
|
|
147
|
+
}
|
|
148
|
+
else if (c === '"') {
|
|
149
|
+
quoted = !quoted;
|
|
150
|
+
}
|
|
151
|
+
else if (!quoted && (c === "#" || c === ";")) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
out += c;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out.trim();
|
|
159
|
+
}
|
|
160
|
+
/** The repo's git dir and common dir for `cwd`, walking up to the filesystem root. */
|
|
161
|
+
export function findGitDirs(cwd, readFile, exists) {
|
|
162
|
+
let dir = path.resolve(cwd);
|
|
163
|
+
for (;;) {
|
|
164
|
+
const dotGit = path.join(dir, ".git");
|
|
165
|
+
if (exists(dotGit)) {
|
|
166
|
+
let gitDir;
|
|
167
|
+
try {
|
|
168
|
+
if (statSync(dotGit).isDirectory())
|
|
169
|
+
gitDir = dotGit;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// fall through to the file form
|
|
173
|
+
}
|
|
174
|
+
if (!gitDir) {
|
|
175
|
+
try {
|
|
176
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFile(dotGit));
|
|
177
|
+
if (m)
|
|
178
|
+
gitDir = path.resolve(dir, m[1].trim());
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// unreadable .git file
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!gitDir)
|
|
185
|
+
return undefined;
|
|
186
|
+
let commonDir = gitDir;
|
|
187
|
+
try {
|
|
188
|
+
commonDir = path.resolve(gitDir, readFile(path.join(gitDir, "commondir")).trim());
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// not a linked worktree
|
|
192
|
+
}
|
|
193
|
+
return { gitDir, commonDir };
|
|
194
|
+
}
|
|
195
|
+
const parent = path.dirname(dir);
|
|
196
|
+
if (parent === dir)
|
|
197
|
+
return undefined;
|
|
198
|
+
dir = parent;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Find persisted git credentials visible from `cwd`. Pure apart from reading
|
|
203
|
+
* config files; never returns a credential value.
|
|
204
|
+
*/
|
|
205
|
+
export function scanGitCredentials(cwd, opts = {}) {
|
|
206
|
+
const env = opts.env ?? process.env;
|
|
207
|
+
const readFile = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
208
|
+
const exists = opts.exists ?? existsSync;
|
|
209
|
+
const findings = [];
|
|
210
|
+
const files = [];
|
|
211
|
+
const addFile = (p) => {
|
|
212
|
+
const r = realOrSelf(p);
|
|
213
|
+
if (!files.includes(r))
|
|
214
|
+
files.push(r);
|
|
215
|
+
return r;
|
|
216
|
+
};
|
|
217
|
+
const roots = [];
|
|
218
|
+
const dirs = findGitDirs(cwd, readFile, exists);
|
|
219
|
+
if (dirs) {
|
|
220
|
+
roots.push(path.join(dirs.commonDir, "config"));
|
|
221
|
+
roots.push(path.join(dirs.gitDir, "config.worktree"));
|
|
222
|
+
}
|
|
223
|
+
const home = env.HOME ?? homedir();
|
|
224
|
+
if (env.GIT_CONFIG_GLOBAL) {
|
|
225
|
+
roots.push(expandHome(env.GIT_CONFIG_GLOBAL, env));
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
roots.push(path.join(home, ".gitconfig"));
|
|
229
|
+
roots.push(path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "git", "config"));
|
|
230
|
+
}
|
|
231
|
+
const visited = new Set();
|
|
232
|
+
const visit = (file, depth) => {
|
|
233
|
+
if (depth > MAX_INCLUDE_DEPTH)
|
|
234
|
+
return;
|
|
235
|
+
const real = realOrSelf(file);
|
|
236
|
+
if (visited.has(real))
|
|
237
|
+
return;
|
|
238
|
+
visited.add(real);
|
|
239
|
+
let text;
|
|
240
|
+
try {
|
|
241
|
+
text = readFile(file);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const includes = [];
|
|
247
|
+
for (const e of parseGitConfig(text)) {
|
|
248
|
+
// A subsection is a URL for http.* and credential.*: never echo one that carries a credential.
|
|
249
|
+
const sub = e.subsection && urlHasCredential(e.subsection) ? "<redacted>" : e.subsection;
|
|
250
|
+
if (e.section === "http" && e.key === "extraheader" && e.value !== "") {
|
|
251
|
+
findings.push({ file: addFile(file), key: `http.${sub ? `${sub}.` : ""}extraheader`, kind: "extraheader" });
|
|
252
|
+
}
|
|
253
|
+
else if (e.section === "credential" && e.key === "helper") {
|
|
254
|
+
const key = `credential.${sub ? `${sub}.` : ""}helper`;
|
|
255
|
+
if (helperHasInlineSecret(e.value)) {
|
|
256
|
+
findings.push({ file: addFile(file), key, kind: "credential-helper" });
|
|
257
|
+
}
|
|
258
|
+
for (const store of storeHelperFiles(e.value, env) ?? []) {
|
|
259
|
+
if (exists(store))
|
|
260
|
+
findings.push({ file: addFile(store), key, kind: "credential-store" });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (e.section === "url" && (e.key === "insteadof" || e.key === "pushinsteadof")) {
|
|
264
|
+
if (sub === "<redacted>" || urlHasCredential(e.value)) {
|
|
265
|
+
findings.push({ file: addFile(file), key: `url.<redacted>.${e.key}`, kind: "url-userinfo" });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (e.section === "remote" && (e.key === "url" || e.key === "pushurl")) {
|
|
269
|
+
if (urlHasCredential(e.value)) {
|
|
270
|
+
findings.push({ file: addFile(file), key: `remote.${sub ?? ""}.${e.key}`, kind: "url-userinfo" });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if ((e.section === "include" || e.section === "includeif") && e.key === "path" && e.value) {
|
|
274
|
+
const p = expandHome(e.value, env);
|
|
275
|
+
includes.push(path.isAbsolute(p) ? p : path.resolve(path.dirname(file), p));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const inc of includes)
|
|
279
|
+
visit(inc, depth + 1);
|
|
280
|
+
};
|
|
281
|
+
for (const root of roots)
|
|
282
|
+
visit(root, 0);
|
|
283
|
+
// Same credential twice in one file is one finding.
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
const unique = findings.filter((f) => {
|
|
286
|
+
const id = `${f.file}\0${f.key}\0${f.kind}`;
|
|
287
|
+
if (seen.has(id))
|
|
288
|
+
return false;
|
|
289
|
+
seen.add(id);
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
return { findings: unique, files };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The files to keep from one node's agent: the scan's files for a read-only
|
|
296
|
+
* node or a staged write node, nothing otherwise. A default write node keeps
|
|
297
|
+
* today's behavior (it may push with the checkout's credential); the
|
|
298
|
+
* recommended `persist-credentials: false` leaves nothing to find.
|
|
299
|
+
*/
|
|
300
|
+
export function gitCredentialMask(cwd, node, opts = {}) {
|
|
301
|
+
if (!node.readOnly && !node.staged)
|
|
302
|
+
return [];
|
|
303
|
+
return scanGitCredentials(cwd, opts).files;
|
|
304
|
+
}
|
|
305
|
+
/** The run-start warning for a checkout that persists a credential. Paths and kinds only. */
|
|
306
|
+
export function gitCredentialWarning(scan) {
|
|
307
|
+
if (scan.findings.length === 0)
|
|
308
|
+
return undefined;
|
|
309
|
+
const where = [...new Set(scan.findings.map((f) => `${f.key} in ${f.file}`))].join("; ");
|
|
310
|
+
return (`persisted git credential found (${where}). Read-only and staged nodes run with it masked where the harness ` +
|
|
311
|
+
`can (reported degraded, refused under strict, where it cannot); other nodes' agents can read it. ` +
|
|
312
|
+
`Recommended: actions/checkout with persist-credentials: false. sweny pushes the PR branch itself with the github skill's token.`);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The `NodePolicy.gitCredentials` fragment for one node run (#473): the
|
|
316
|
+
* credential files to keep from a read-only or staged node's agent, or
|
|
317
|
+
* nothing. Adapters spread it into the policy they gate and wrap with.
|
|
318
|
+
*/
|
|
319
|
+
export function gitCredentialPolicy(cwd, node, opts = {}) {
|
|
320
|
+
const files = gitCredentialMask(cwd, { readOnly: node.readOnly, staged: node.noPush === true }, opts);
|
|
321
|
+
return files.length > 0 ? { gitCredentials: files } : {};
|
|
322
|
+
}
|
package/dist/harness/acp.js
CHANGED
|
@@ -51,6 +51,7 @@ import { consoleLogger } from "../types.js";
|
|
|
51
51
|
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
52
52
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
53
53
|
import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
|
|
54
|
+
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
54
55
|
import { buildNodePrompt } from "./prompts.js";
|
|
55
56
|
import { makeAbort } from "./abort.js";
|
|
56
57
|
import { parseToolResultContent, schemaMismatches, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
@@ -700,6 +701,8 @@ export class AcpHarness {
|
|
|
700
701
|
sandbox: mode,
|
|
701
702
|
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
702
703
|
...(req.agentAccess?.noPush && !compiled.readOnly ? { stagedWrite: true } : {}),
|
|
704
|
+
// #473: a credential the checkout persisted stays unreadable to read-only and staged nodes.
|
|
705
|
+
...gitCredentialPolicy(this.cwd, { readOnly: compiled.readOnly, noPush: req.agentAccess?.noPush }),
|
|
703
706
|
};
|
|
704
707
|
// ACP has no sandbox of its own: any mode but off needs the process wrapper.
|
|
705
708
|
const wrapper = mode !== "off" && this.sandboxWrapper !== null
|
|
@@ -44,6 +44,13 @@ export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edi
|
|
|
44
44
|
* `tools.deny` classes compile to these names on `disallowedTools`.
|
|
45
45
|
*/
|
|
46
46
|
export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly string[]>>;
|
|
47
|
+
/**
|
|
48
|
+
* A Claude Code permission rule that denies reading one absolute path (#473).
|
|
49
|
+
* `//` marks an absolute path in permission rules. Read rules cover Read and,
|
|
50
|
+
* best effort, Grep and Glob; with the SDK sandbox on, its filesystem
|
|
51
|
+
* `denyRead` (merged with these rules) covers Bash.
|
|
52
|
+
*/
|
|
53
|
+
export declare function claudeReadDenyRule(absPath: string): string;
|
|
47
54
|
/** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
|
|
48
55
|
export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
|
|
49
56
|
/** How sweny resolves which credentials reach the Claude Code subprocess. */
|
|
@@ -12,7 +12,8 @@ import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude
|
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { consoleLogger } from "../types.js";
|
|
14
14
|
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
|
-
import { policyGate } from "./policy.js";
|
|
15
|
+
import { gitCredentialGap, policyGate } from "./policy.js";
|
|
16
|
+
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
16
17
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
17
18
|
import { ask as coreAsk, evaluate as coreEvaluate, buildEvaluatePrompt, buildNodePrompt } from "./prompts.js";
|
|
18
19
|
import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } from "./tool-bridge/protocol.js";
|
|
@@ -86,6 +87,15 @@ export const CLAUDE_TOOLS_BY_CLASS = {
|
|
|
86
87
|
net: ["WebFetch", "WebSearch"],
|
|
87
88
|
subagent: ["Task", "Agent"],
|
|
88
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* A Claude Code permission rule that denies reading one absolute path (#473).
|
|
92
|
+
* `//` marks an absolute path in permission rules. Read rules cover Read and,
|
|
93
|
+
* best effort, Grep and Glob; with the SDK sandbox on, its filesystem
|
|
94
|
+
* `denyRead` (merged with these rules) covers Bash.
|
|
95
|
+
*/
|
|
96
|
+
export function claudeReadDenyRule(absPath) {
|
|
97
|
+
return `Read(/${absPath.startsWith("/") ? absPath : `/${absPath}`})`;
|
|
98
|
+
}
|
|
89
99
|
/** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
|
|
90
100
|
export function compileClaudeCodeDeny(policy, legacy = []) {
|
|
91
101
|
return [
|
|
@@ -324,6 +334,8 @@ export class ClaudeCodeHarness {
|
|
|
324
334
|
},
|
|
325
335
|
stagedWrite: !!req.agentAccess?.noPush && !readOnly,
|
|
326
336
|
strict: policy.strict || this.policyMode === "strict",
|
|
337
|
+
// #473: files holding a credential the checkout persisted, for a read-only or staged node.
|
|
338
|
+
gitCredentials: gitCredentialPolicy(this.cwd, { readOnly, noPush: req.agentAccess?.noPush }).gitCredentials,
|
|
327
339
|
degraded,
|
|
328
340
|
});
|
|
329
341
|
return {
|
|
@@ -338,7 +350,7 @@ export class ClaudeCodeHarness {
|
|
|
338
350
|
// Dry run (#380): external MCP servers cannot be classified per tool, so
|
|
339
351
|
// they are unknown, and unknown means write. Drop them, and disallow the
|
|
340
352
|
// built-ins that can change the workspace or shell out.
|
|
341
|
-
|
|
353
|
+
let disallowedTools = readOnly
|
|
342
354
|
? [...new Set([...(opts.disallowedTools ?? []), ...READ_ONLY_DISALLOWED_TOOLS])]
|
|
343
355
|
: opts.disallowedTools;
|
|
344
356
|
const effectiveModel = model ?? this.model;
|
|
@@ -379,6 +391,35 @@ export class ClaudeCodeHarness {
|
|
|
379
391
|
}
|
|
380
392
|
opts.degraded?.push(gap);
|
|
381
393
|
}
|
|
394
|
+
// #473: a credential the checkout persisted (actions/checkout's default).
|
|
395
|
+
// Read deny rules keep it from Read, Grep and Glob; the SDK sandbox's
|
|
396
|
+
// filesystem deny keeps it from Bash (and stops a rename around the deny).
|
|
397
|
+
// A node with Bash and no sandbox cannot be held to it.
|
|
398
|
+
let sandboxSettings = sandbox.settings;
|
|
399
|
+
const credentialFiles = opts.gitCredentials ?? [];
|
|
400
|
+
if (credentialFiles.length > 0) {
|
|
401
|
+
disallowedTools = [...new Set([...(disallowedTools ?? []), ...credentialFiles.map(claudeReadDenyRule)])];
|
|
402
|
+
if (sandboxSettings) {
|
|
403
|
+
const fsDeny = sandboxSettings.filesystem ?? {};
|
|
404
|
+
sandboxSettings = {
|
|
405
|
+
...sandboxSettings,
|
|
406
|
+
filesystem: {
|
|
407
|
+
...fsDeny,
|
|
408
|
+
denyRead: [...new Set([...(fsDeny.denyRead ?? []), ...credentialFiles])],
|
|
409
|
+
denyWrite: [...new Set([...(fsDeny.denyWrite ?? []), ...credentialFiles])],
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
else if (!disallowedTools.includes("Bash")) {
|
|
414
|
+
const gap = gitCredentialGap(credentialFiles);
|
|
415
|
+
if (opts.strict) {
|
|
416
|
+
const error = `strict policy: ${gap}`;
|
|
417
|
+
this.logger.error(error);
|
|
418
|
+
return { status: "failed", data: { error, refused: true }, toolCalls: [] };
|
|
419
|
+
}
|
|
420
|
+
opts.degraded?.push(gap);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
382
423
|
// Tool-call accounting (Fix #1).
|
|
383
424
|
//
|
|
384
425
|
// Stream-driven recording, correlated by tool_use_id:
|
|
@@ -469,7 +510,7 @@ export class ClaudeCodeHarness {
|
|
|
469
510
|
env,
|
|
470
511
|
permissionMode: "bypassPermissions",
|
|
471
512
|
allowDangerouslySkipPermissions: true,
|
|
472
|
-
...(
|
|
513
|
+
...(sandboxSettings ? { sandbox: sandboxSettings } : {}),
|
|
473
514
|
stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
|
|
474
515
|
...(abort ? { abortController: abort.controller } : {}),
|
|
475
516
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
package/dist/harness/codex.js
CHANGED
|
@@ -31,6 +31,7 @@ import { consoleLogger } from "../types.js";
|
|
|
31
31
|
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
32
32
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
33
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
|
+
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
34
35
|
import { buildNodePrompt } from "./prompts.js";
|
|
35
36
|
import { makeAbort } from "./abort.js";
|
|
36
37
|
import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
@@ -744,6 +745,8 @@ export class CodexHarness {
|
|
|
744
745
|
sandbox: mode,
|
|
745
746
|
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
746
747
|
...(req.agentAccess?.noPush && !compiled.readOnly ? { stagedWrite: true } : {}),
|
|
748
|
+
// #473: a credential the checkout persisted stays unreadable to read-only and staged nodes.
|
|
749
|
+
...gitCredentialPolicy(this.cwd, { readOnly: compiled.readOnly, noPush: req.agentAccess?.noPush }),
|
|
747
750
|
};
|
|
748
751
|
// Codex's network is one switch, not a host list, so any sandbox mode but
|
|
749
752
|
// off needs the process wrapper for full containment.
|
package/dist/harness/pi.js
CHANGED
|
@@ -37,6 +37,7 @@ import { consoleLogger } from "../types.js";
|
|
|
37
37
|
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
38
38
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
39
39
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
40
|
+
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
40
41
|
import { buildNodePrompt } from "./prompts.js";
|
|
41
42
|
import { makeAbort } from "./abort.js";
|
|
42
43
|
import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
@@ -743,6 +744,8 @@ export class PiHarness {
|
|
|
743
744
|
sandbox: mode,
|
|
744
745
|
...(held.length > 0 ? { agentCredentials: held } : {}),
|
|
745
746
|
...(req.agentAccess?.noPush && !compiled.policy.readOnly ? { stagedWrite: true } : {}),
|
|
747
|
+
// #473: a credential the checkout persisted stays unreadable to read-only and staged nodes.
|
|
748
|
+
...gitCredentialPolicy(this.cwd, { readOnly: compiled.policy.readOnly, noPush: req.agentAccess?.noPush }),
|
|
746
749
|
};
|
|
747
750
|
const needsWrapper = mode !== "off";
|
|
748
751
|
const wrapper = needsWrapper && this.sandboxWrapper !== null
|
package/dist/harness/policy.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare const TOOL_CLASSES: readonly ToolClass[];
|
|
|
30
30
|
export declare function isToolClass(name: string): name is ToolClass;
|
|
31
31
|
/** The tool classes a harness can deny natively. */
|
|
32
32
|
export declare function nativeDenyClasses(caps: HarnessCapabilities): ToolClass[];
|
|
33
|
+
/**
|
|
34
|
+
* One `degraded` line for credential files nobody keeps from the agent (#473).
|
|
35
|
+
* Paths only, never values.
|
|
36
|
+
*/
|
|
37
|
+
export declare function gitCredentialGap(files: readonly string[]): string;
|
|
33
38
|
export declare function policyGate(caps: HarnessCapabilities, policy: NodePolicy, wrappers?: PolicyWrappers): PolicyGateResult;
|
|
34
39
|
/**
|
|
35
40
|
* budgetGate (#449): can this harness keep a spend budget? Pure; the executor
|
package/dist/harness/policy.js
CHANGED
|
@@ -42,6 +42,14 @@ export function nativeDenyClasses(caps) {
|
|
|
42
42
|
const set = new Set([...base, ...(caps.denyClasses ?? [])]);
|
|
43
43
|
return TOOL_CLASSES.filter((c) => set.has(c));
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One `degraded` line for credential files nobody keeps from the agent (#473).
|
|
47
|
+
* Paths only, never values.
|
|
48
|
+
*/
|
|
49
|
+
export function gitCredentialGap(files) {
|
|
50
|
+
return (`git credential: ${files.join(", ")} holds a persisted git credential (actions/checkout persist-credentials) ` +
|
|
51
|
+
`and nothing keeps this node's agent from reading it; set persist-credentials: false, or run under the sandbox`);
|
|
52
|
+
}
|
|
45
53
|
export function policyGate(caps, policy, wrappers = {}) {
|
|
46
54
|
const unenforced = [];
|
|
47
55
|
const wrapped = [];
|
|
@@ -56,6 +64,12 @@ export function policyGate(caps, policy, wrappers = {}) {
|
|
|
56
64
|
unenforced.push(`read-only: the agent process holds write credentials (${held.join(", ")}) and network access; ` +
|
|
57
65
|
`a read-only filesystem mount does not stop API writes`);
|
|
58
66
|
}
|
|
67
|
+
// #473: a credential the checkout persisted on disk. Only a read deny
|
|
68
|
+
// keeps it from the agent; env scoping cannot reach a file.
|
|
69
|
+
const gitCredentials = policy.gitCredentials ?? [];
|
|
70
|
+
if (gitCredentials.length > 0 && !wrappers.readDeny) {
|
|
71
|
+
unenforced.push(gitCredentialGap(gitCredentials));
|
|
72
|
+
}
|
|
59
73
|
if (policy.deny.length > 0) {
|
|
60
74
|
const native = nativeDenyClasses(caps);
|
|
61
75
|
const unmappable = policy.deny.filter((c) => !native.includes(c));
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* host network except through that proxy.
|
|
20
20
|
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
21
|
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
-
* unreadable
|
|
22
|
+
* unreadable, and so is a git credential the checkout persisted when the
|
|
23
|
+
* node is read-only or staged (#473, `denyRead`). Sibling homes under the same configured scratch root are
|
|
23
24
|
* hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
|
|
24
25
|
* workspace read-only too. All concurrent credential-bearing adapters must
|
|
25
26
|
* use the same scratch root; different roots are not mutually isolated.
|
|
@@ -55,6 +56,12 @@ export interface SandboxWrapRequest extends AgentSpawn {
|
|
|
55
56
|
egress: readonly string[];
|
|
56
57
|
/** Dry run: the workspace is read-only too (only the scratch HOME is writable). */
|
|
57
58
|
readOnly?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Files the process must not read, nor move or rewrite (#473: a git
|
|
61
|
+
* credential `actions/checkout` persisted). On Linux srt masks each with
|
|
62
|
+
* /dev/null, so git sees an empty file; on macOS reads fail.
|
|
63
|
+
*/
|
|
64
|
+
denyRead?: readonly string[];
|
|
58
65
|
}
|
|
59
66
|
/** The spawn to run instead, plus the scratch it owns. */
|
|
60
67
|
export interface WrappedSpawn extends AgentSpawn {
|
|
@@ -102,7 +109,7 @@ export declare function isSrtDomainPattern(entry: string): boolean;
|
|
|
102
109
|
* The srt settings for one wrapped spawn. Pure apart from `exists`.
|
|
103
110
|
* `credentialHome` is the operator's real HOME (not the scratch HOME).
|
|
104
111
|
*/
|
|
105
|
-
export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly">, opts: {
|
|
112
|
+
export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly" | "denyRead">, opts: {
|
|
106
113
|
home: string;
|
|
107
114
|
credentialHome: string;
|
|
108
115
|
exists?: (p: string) => boolean;
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* host network except through that proxy.
|
|
20
20
|
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
21
|
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
-
* unreadable
|
|
22
|
+
* unreadable, and so is a git credential the checkout persisted when the
|
|
23
|
+
* node is read-only or staged (#473, `denyRead`). Sibling homes under the same configured scratch root are
|
|
23
24
|
* hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
|
|
24
25
|
* workspace read-only too. All concurrent credential-bearing adapters must
|
|
25
26
|
* use the same scratch root; different roots are not mutually isolated.
|
|
@@ -126,10 +127,14 @@ export function buildSrtSettings(req, opts) {
|
|
|
126
127
|
// must stay hidden too. srt allowRead carves out only our own HOME.
|
|
127
128
|
if (opts.isolationRoot)
|
|
128
129
|
denyRead.push(opts.isolationRoot);
|
|
130
|
+
// #473: persisted git credentials. Also unwritable, so a writable workspace
|
|
131
|
+
// cannot rename one out from under its read deny.
|
|
132
|
+
const masked = [...new Set((req.denyRead ?? []).map(real))].filter((p) => exists(p));
|
|
133
|
+
denyRead.push(...masked.filter((p) => !denyRead.includes(p)));
|
|
129
134
|
const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
|
|
130
135
|
return {
|
|
131
136
|
network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|
|
132
|
-
filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite:
|
|
137
|
+
filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite: masked },
|
|
133
138
|
};
|
|
134
139
|
}
|
|
135
140
|
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|
|
@@ -149,7 +154,7 @@ export function scratchEnv(env, home) {
|
|
|
149
154
|
export class SrtSandboxWrapper {
|
|
150
155
|
opts;
|
|
151
156
|
backend = "srt";
|
|
152
|
-
provides = { sandbox: true, egress: true, readOnlyMount: true };
|
|
157
|
+
provides = { sandbox: true, egress: true, readOnlyMount: true, readDeny: true };
|
|
153
158
|
constructor(opts) {
|
|
154
159
|
this.opts = opts;
|
|
155
160
|
}
|
|
@@ -335,7 +340,12 @@ export async function prepareAgentSpawn(opts) {
|
|
|
335
340
|
...parseList(env.SWENY_SANDBOX_ALLOWED_DOMAINS),
|
|
336
341
|
...(opts.harnessEgress ?? []),
|
|
337
342
|
];
|
|
338
|
-
const wrapped = await wrapper.wrap({
|
|
343
|
+
const wrapped = await wrapper.wrap({
|
|
344
|
+
...opts.spawn,
|
|
345
|
+
egress,
|
|
346
|
+
readOnly: policy.readOnly,
|
|
347
|
+
...(policy.gitCredentials && policy.gitCredentials.length > 0 ? { denyRead: policy.gitCredentials } : {}),
|
|
348
|
+
});
|
|
339
349
|
const { home, cleanup, ...spawn } = wrapped;
|
|
340
350
|
return { degraded: gate.degraded, spawn, wrappedBy: wrapper.backend, home, cleanup };
|
|
341
351
|
}
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -97,6 +97,14 @@ export interface NodePolicy {
|
|
|
97
97
|
* adapters, which know whether the run is sandboxed.
|
|
98
98
|
*/
|
|
99
99
|
stagedWrite?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Files holding a persisted git credential (#473: the token
|
|
102
|
+
* `actions/checkout` writes by default) that this node's agent must not
|
|
103
|
+
* read. Set by adapters for a read-only node or a staged write node when
|
|
104
|
+
* the checkout has one (see git-credentials.ts). The process wrapper denies
|
|
105
|
+
* reads of them; without it, reported, and refused under `strict`.
|
|
106
|
+
*/
|
|
107
|
+
gitCredentials?: string[];
|
|
100
108
|
}
|
|
101
109
|
/** Process-level wrappers the host provides (OS sandbox, egress proxy, read-only mount). */
|
|
102
110
|
export interface PolicyWrappers {
|
|
@@ -104,6 +112,8 @@ export interface PolicyWrappers {
|
|
|
104
112
|
sandbox?: boolean;
|
|
105
113
|
egress?: boolean;
|
|
106
114
|
readOnlyMount?: boolean;
|
|
115
|
+
/** The wrapper can make named files unreadable to the agent (srt `denyRead`). */
|
|
116
|
+
readDeny?: boolean;
|
|
107
117
|
}
|
|
108
118
|
export interface PolicyGateResult {
|
|
109
119
|
/** Every opinion this run could not honor natively. Empty for Claude Code. */
|
package/dist/index.d.ts
CHANGED
|
@@ -61,5 +61,7 @@ export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templat
|
|
|
61
61
|
export type { Templates } from "./templates.js";
|
|
62
62
|
export { toMermaid, toMermaidBlock } from "./mermaid.js";
|
|
63
63
|
export type { MermaidOptions, NodeStatus } from "./mermaid.js";
|
|
64
|
+
export { SWENY_TAGLINE, PALETTE, ROLE_COLORS, SURFACE_COLORS, GLYPHS, SPINNER_FRAMES, SPINNER_INTERVAL_MS, TICKET_BOX, MERMAID_CLASS_DEFS, MERMAID_EDGE_STYLES, } from "./theme.js";
|
|
65
|
+
export type { ColorRole, GlyphKey, GlyphSet, TicketBox, DiagramNodeStatus } from "./theme.js";
|
|
64
66
|
export { loadConfigFile } from "./cli/config-file.js";
|
|
65
67
|
export type { FileConfig } from "./cli/config-file.js";
|
package/dist/index.js
CHANGED
|
@@ -62,5 +62,7 @@ export { buildWorkflow, refineWorkflow } from "./workflow-builder.js";
|
|
|
62
62
|
export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templates.js";
|
|
63
63
|
// Mermaid export
|
|
64
64
|
export { toMermaid, toMermaidBlock } from "./mermaid.js";
|
|
65
|
+
// Visual tokens (#479): palette, color roles, glyphs, diagram classDefs. Browser-safe.
|
|
66
|
+
export { SWENY_TAGLINE, PALETTE, ROLE_COLORS, SURFACE_COLORS, GLYPHS, SPINNER_FRAMES, SPINNER_INTERVAL_MS, TICKET_BOX, MERMAID_CLASS_DEFS, MERMAID_EDGE_STYLES, } from "./theme.js";
|
|
65
67
|
// Config file
|
|
66
68
|
export { loadConfigFile } from "./cli/config-file.js";
|