@sweny-ai/core 0.1.170 → 0.1.172

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.
Files changed (64) hide show
  1. package/dist/agent-env.d.ts +2 -1
  2. package/dist/agent-env.js +9 -3
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +3 -0
  5. package/dist/budget.js +5 -1
  6. package/dist/cli/check.js +12 -12
  7. package/dist/cli/comment-output.d.ts +4 -2
  8. package/dist/cli/comment-output.js +16 -8
  9. package/dist/cli/config.js +24 -24
  10. package/dist/cli/e2e.js +17 -17
  11. package/dist/cli/main.js +42 -26
  12. package/dist/cli/marketplace.js +3 -3
  13. package/dist/cli/new.js +11 -8
  14. package/dist/cli/output.d.ts +3 -2
  15. package/dist/cli/output.js +39 -37
  16. package/dist/cli/progress.d.ts +33 -0
  17. package/dist/cli/progress.js +69 -0
  18. package/dist/cli/publish.js +2 -2
  19. package/dist/cli/record.d.ts +66 -0
  20. package/dist/cli/record.js +344 -0
  21. package/dist/cli/renderer.d.ts +4 -0
  22. package/dist/cli/renderer.js +20 -13
  23. package/dist/cli/run-output.d.ts +13 -6
  24. package/dist/cli/run-output.js +36 -26
  25. package/dist/cli/runs.js +17 -11
  26. package/dist/cli/setup.js +7 -7
  27. package/dist/cli/style.d.ts +26 -0
  28. package/dist/cli/style.js +48 -0
  29. package/dist/cli/terminal.d.ts +47 -0
  30. package/dist/cli/terminal.js +123 -0
  31. package/dist/cli/ticket.d.ts +89 -0
  32. package/dist/cli/ticket.js +218 -0
  33. package/dist/cli/try.d.ts +19 -8
  34. package/dist/cli/try.js +97 -65
  35. package/dist/cli/upgrade.js +1 -1
  36. package/dist/executor.d.ts +8 -1
  37. package/dist/executor.js +20 -2
  38. package/dist/git-credentials.d.ts +106 -0
  39. package/dist/git-credentials.js +322 -0
  40. package/dist/harness/acp.js +3 -0
  41. package/dist/harness/claude-code.d.ts +7 -0
  42. package/dist/harness/claude-code.js +44 -3
  43. package/dist/harness/codex.js +3 -0
  44. package/dist/harness/pi.js +3 -0
  45. package/dist/harness/policy.d.ts +5 -0
  46. package/dist/harness/policy.js +14 -0
  47. package/dist/harness/sandbox-wrapper.d.ts +9 -2
  48. package/dist/harness/sandbox-wrapper.js +14 -4
  49. package/dist/harness/types.d.ts +10 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.js +2 -0
  52. package/dist/mermaid.d.ts +10 -5
  53. package/dist/mermaid.js +11 -13
  54. package/dist/safe-outputs.js +5 -0
  55. package/dist/skills/git-push.d.ts +62 -0
  56. package/dist/skills/git-push.js +132 -0
  57. package/dist/skills/github.d.ts +17 -0
  58. package/dist/skills/github.js +23 -0
  59. package/dist/theme.d.ts +185 -0
  60. package/dist/theme.js +183 -0
  61. package/dist/workflows/browser.js +2 -2
  62. package/dist/workflows/implement.yml +2 -1
  63. package/dist/workflows/triage.yml +2 -1
  64. package/package.json +6 -1
@@ -0,0 +1,106 @@
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
+ export { gitCredentialGap } from "./harness/policy.js";
33
+ export type GitCredentialKind = "extraheader" | "credential-helper" | "credential-store" | "url-userinfo";
34
+ export interface GitCredentialFinding {
35
+ /** The file holding the credential (absolute, symlinks resolved). */
36
+ file: string;
37
+ /** The config key, with any embedded credential redacted (`url.<redacted>.insteadof`). */
38
+ key: string;
39
+ kind: GitCredentialKind;
40
+ }
41
+ export interface GitCredentialScan {
42
+ findings: GitCredentialFinding[];
43
+ /** Every file to keep from the agent, deduplicated, in scan order. */
44
+ files: string[];
45
+ }
46
+ export interface ScanGitCredentialsOptions {
47
+ env?: Record<string, string | undefined>;
48
+ /** Test seam. Default: `fs.readFileSync(p, "utf8")`; a throw means "not there". */
49
+ readFile?: (p: string) => string;
50
+ /** Test seam. Default: `fs.existsSync`. */
51
+ exists?: (p: string) => boolean;
52
+ }
53
+ /** Does a URL carry a credential in its userinfo (`https://user:pass@`, `https://<token>@`)? */
54
+ export declare function urlHasCredential(url: string): boolean;
55
+ /** Does a `credential.helper` value hold the secret itself (an inline shell helper)? */
56
+ export declare function helperHasInlineSecret(value: string): boolean;
57
+ /** The file a `store` credential helper writes, or undefined when the helper is not `store`. */
58
+ export declare function storeHelperFiles(value: string, env: Record<string, string | undefined>): string[] | undefined;
59
+ interface ConfigEntry {
60
+ section: string;
61
+ /** Case preserved, as git does. */
62
+ subsection?: string;
63
+ key: string;
64
+ value: string;
65
+ }
66
+ /**
67
+ * Minimal git-config parser: `[section]`, `[section "sub"]`, the legacy
68
+ * `[section.sub]`, `key = value`, bare `key` (boolean), quoted values with
69
+ * escapes, `#` and `;` comments, and `\` line continuations. Section and key
70
+ * names are lowercased; subsections keep their case. Enough to find keys; not
71
+ * a validator.
72
+ */
73
+ export declare function parseGitConfig(text: string): ConfigEntry[];
74
+ /** The repo's git dir and common dir for `cwd`, walking up to the filesystem root. */
75
+ export declare function findGitDirs(cwd: string, readFile: (p: string) => string, exists: (p: string) => boolean): {
76
+ gitDir: string;
77
+ commonDir: string;
78
+ } | undefined;
79
+ /**
80
+ * Find persisted git credentials visible from `cwd`. Pure apart from reading
81
+ * config files; never returns a credential value.
82
+ */
83
+ export declare function scanGitCredentials(cwd: string, opts?: ScanGitCredentialsOptions): GitCredentialScan;
84
+ /**
85
+ * The files to keep from one node's agent: the scan's files for a read-only
86
+ * node or a staged write node, nothing otherwise. A default write node keeps
87
+ * today's behavior (it may push with the checkout's credential); the
88
+ * recommended `persist-credentials: false` leaves nothing to find.
89
+ */
90
+ export declare function gitCredentialMask(cwd: string, node: {
91
+ readOnly: boolean;
92
+ staged: boolean;
93
+ }, opts?: ScanGitCredentialsOptions): string[];
94
+ /** The run-start warning for a checkout that persists a credential. Paths and kinds only. */
95
+ export declare function gitCredentialWarning(scan: GitCredentialScan): string | undefined;
96
+ /**
97
+ * The `NodePolicy.gitCredentials` fragment for one node run (#473): the
98
+ * credential files to keep from a read-only or staged node's agent, or
99
+ * nothing. Adapters spread it into the policy they gate and wrap with.
100
+ */
101
+ export declare function gitCredentialPolicy(cwd: string, node: {
102
+ readOnly: boolean;
103
+ noPush?: boolean;
104
+ }, opts?: ScanGitCredentialsOptions): {
105
+ gitCredentials?: string[];
106
+ };
@@ -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
+ }
@@ -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
- const disallowedTools = readOnly
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
- ...(sandbox.settings ? { sandbox: sandbox.settings } : {}),
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 } : {}),
@@ -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.
@@ -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
@@ -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
@@ -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. Sibling homes under the same configured scratch root are
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;