Skip to content

Commit e49df7d

Browse files
committed
fix: isolate attribution settings access
1 parent 939ad46 commit e49df7d

6 files changed

Lines changed: 277 additions & 54 deletions

File tree

.agents/skills/disabling-ai-attribution/SKILL.md

Lines changed: 20 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,72 +5,38 @@ description: Disables commit, pull request, and session attribution in contribut
55

66
# Disabling AI attribution
77

8-
Update the contributor's user-level settings without changing repository files
9-
outside this skill. Preserve every unrelated setting.
8+
Update the contributor's user-level settings through the repository's settings
9+
helper. Never read or print the settings files directly.
1010

1111
## Workflow
1212

13-
### 1. Inspect before editing
13+
### 1. Run the settings helper
1414

15-
Find the existing user settings files. Expand `~` using the current user's home
16-
directory.
15+
Run:
1716

18-
- Claude Code: `~/.claude/settings.json`
19-
- Amp: `~/.config/amp/settings.json` or `settings.jsonc`
20-
- Codex: `~/.codex/config.toml`
21-
22-
If both Amp files exist, stop and ask which one to update. If a settings file is
23-
malformed, report the parse error instead of replacing it.
24-
25-
### 2. Update Claude Code
26-
27-
Merge this object into `~/.claude/settings.json`:
28-
29-
```json
30-
{
31-
"attribution": {
32-
"commit": "",
33-
"pr": "",
34-
"sessionUrl": false
35-
}
36-
}
37-
```
38-
39-
Create the directory and file when missing. Preserve other keys inside
40-
`attribution` and elsewhere in the file.
41-
42-
### 3. Update Amp
43-
44-
Set these top-level keys in the existing Amp settings file:
45-
46-
```json
47-
{
48-
"amp.git.commit.ampThread.enabled": false,
49-
"amp.git.commit.coauthor.enabled": false
50-
}
17+
```bash
18+
pnpm exec tsx scripts/skills/update-ai-attribution.ts
5119
```
5220

53-
Create `~/.config/amp/settings.json` when neither supported file exists. Preserve
54-
comments when updating `settings.jsonc`.
55-
56-
### 4. Check Codex
21+
The helper updates only the Claude Code and Amp attribution keys. It preserves
22+
all other values and JSONC comments. Its output contains status labels only,
23+
never configuration values. Do not replace this command with file reads or
24+
direct edits.
5725

58-
Do not add `commit_attribution` or `features.codex_git_commit` to Codex config.
59-
Current Codex versions removed those local controls. Attribution is determined
60-
by the authenticated workspace's `commit_attribution_enabled` policy.
26+
### 2. Handle the result
6127

62-
Report whether Codex is installed. If it is, explain that the contributor or a
63-
workspace administrator must disable commit attribution in the Codex workspace
64-
settings. Do not claim Codex attribution is disabled from a local file change.
28+
Report each status from the helper. If it reports an error, relay the error and
29+
stop. Do not inspect the settings file to diagnose it.
6530

66-
### 5. Verify
31+
Codex reports `workspace setting required` because current versions removed the
32+
local attribution controls. The contributor or a workspace administrator must
33+
disable commit attribution in the Codex workspace settings. Do not add
34+
`commit_attribution` or `features.codex_git_commit` to Codex config.
6735

68-
Parse the updated JSON or JSONC files and read back the exact keys. If the tool
69-
is installed, run a non-mutating command such as `amp --help` or
70-
`claude --version` to catch settings-load errors.
36+
### 3. Verify
7137

72-
Report each tool as updated, already configured, unavailable, or requiring a
73-
workspace setting. Never print unrelated settings, tokens, or credentials.
38+
Run the helper a second time. Claude Code and Amp should report
39+
`already configured`. Do not open their settings files to verify the result.
7440

7541
## References
7642

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
"happy-dom": "catalog:",
187187
"husky": "catalog:",
188188
"jsdom": "catalog:",
189+
"jsonc-parser": "catalog:",
189190
"knip": "catalog:",
190191
"lint-staged": "catalog:",
191192
"markdown-table": "catalog:",

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ catalog:
9898
husky: ^9.1.7
9999
jsdom: ^29.1.1
100100
jsonata: ^2.1.0
101+
jsonc-parser: 3.3.1
101102
knip: ^6.27.0
102103
lenis: ^1.3.21
103104
lint-staged: ^16.2.7
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync
7+
} from 'node:fs'
8+
import { tmpdir } from 'node:os'
9+
import { join } from 'node:path'
10+
11+
import { parse } from 'jsonc-parser'
12+
13+
import {
14+
formatResults,
15+
updateAttributionSettings
16+
} from './update-ai-attribution'
17+
18+
describe('updateAttributionSettings', () => {
19+
it('updates only attribution settings without exposing other values', () => {
20+
const home = mkdtempSync(join(tmpdir(), 'update-ai-attribution-'))
21+
try {
22+
const claudePath = join(home, '.claude', 'settings.json')
23+
const ampPath = join(home, '.config', 'amp', 'settings.jsonc')
24+
mkdirSync(join(home, '.claude'), { recursive: true })
25+
mkdirSync(join(home, '.config', 'amp'), { recursive: true })
26+
writeFileSync(
27+
claudePath,
28+
JSON.stringify({
29+
token: 'claude-secret',
30+
attribution: { custom: true }
31+
})
32+
)
33+
writeFileSync(
34+
ampPath,
35+
'{\n // Keep this comment.\n "token": "amp-secret"\n}\n'
36+
)
37+
38+
const firstResults = updateAttributionSettings(home)
39+
const output = formatResults(firstResults)
40+
const claude: unknown = parse(readFileSync(claudePath, 'utf8'))
41+
const ampContent = readFileSync(ampPath, 'utf8')
42+
const amp: unknown = parse(ampContent)
43+
44+
expect(firstResults.map(({ outcome }) => outcome)).toEqual([
45+
'updated',
46+
'updated',
47+
'workspace setting required'
48+
])
49+
expect(output).not.toContain('claude-secret')
50+
expect(output).not.toContain('amp-secret')
51+
expect(claude).toMatchObject({
52+
token: 'claude-secret',
53+
attribution: {
54+
custom: true,
55+
commit: '',
56+
pr: '',
57+
sessionUrl: false
58+
}
59+
})
60+
expect(ampContent).toContain('// Keep this comment.')
61+
expect(amp).toMatchObject({
62+
token: 'amp-secret',
63+
'amp.git.commit.ampThread.enabled': false,
64+
'amp.git.commit.coauthor.enabled': false
65+
})
66+
expect(
67+
updateAttributionSettings(home).map(({ outcome }) => outcome)
68+
).toEqual([
69+
'already configured',
70+
'already configured',
71+
'workspace setting required'
72+
])
73+
} finally {
74+
rmSync(home, { recursive: true, force: true })
75+
}
76+
})
77+
})
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env tsx
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
readFileSync,
7+
renameSync,
8+
rmSync,
9+
statSync,
10+
writeFileSync
11+
} from 'node:fs'
12+
import { homedir } from 'node:os'
13+
import { dirname, join } from 'node:path'
14+
import { pathToFileURL } from 'node:url'
15+
import { parseArgs } from 'node:util'
16+
17+
import { applyEdits, modify, parse } from 'jsonc-parser'
18+
import type { FormattingOptions, ParseError } from 'jsonc-parser'
19+
20+
type SettingValue = boolean | string
21+
type Tool = 'amp' | 'claude' | 'codex'
22+
type Outcome =
23+
| 'already configured'
24+
| 'error'
25+
| 'updated'
26+
| 'workspace setting required'
27+
28+
interface Setting {
29+
path: string[]
30+
value: SettingValue
31+
}
32+
33+
interface UpdateResult {
34+
tool: Tool
35+
outcome: Outcome
36+
detail?: string
37+
}
38+
39+
const CLAUDE_SETTINGS: Setting[] = [
40+
{ path: ['attribution', 'commit'], value: '' },
41+
{ path: ['attribution', 'pr'], value: '' },
42+
{ path: ['attribution', 'sessionUrl'], value: false }
43+
]
44+
45+
const AMP_SETTINGS: Setting[] = [
46+
{ path: ['amp.git.commit.ampThread.enabled'], value: false },
47+
{ path: ['amp.git.commit.coauthor.enabled'], value: false }
48+
]
49+
50+
function isRecord(value: unknown): value is Record<string, unknown> {
51+
return typeof value === 'object' && value !== null && !Array.isArray(value)
52+
}
53+
54+
function readPath(value: unknown, path: string[]): unknown {
55+
let current = value
56+
for (const key of path) {
57+
if (!isRecord(current)) return undefined
58+
current = current[key]
59+
}
60+
return current
61+
}
62+
63+
function formattingOptions(content: string): FormattingOptions {
64+
return {
65+
insertSpaces: true,
66+
tabSize: 2,
67+
eol: content.includes('\r\n') ? '\r\n' : '\n'
68+
}
69+
}
70+
71+
function parseSettings(content: string): Record<string, unknown> {
72+
const errors: ParseError[] = []
73+
const parsed: unknown = parse(content, errors, {
74+
allowTrailingComma: true,
75+
disallowComments: false
76+
})
77+
if (errors.length > 0 || !isRecord(parsed)) {
78+
throw new Error('settings file is not a valid JSON object')
79+
}
80+
return parsed
81+
}
82+
83+
function writeSettings(path: string, content: string) {
84+
mkdirSync(dirname(path), { recursive: true })
85+
const temporaryPath = `${path}.${process.pid}.tmp`
86+
const mode = existsSync(path) ? statSync(path).mode : undefined
87+
88+
try {
89+
writeFileSync(temporaryPath, content, { mode })
90+
renameSync(temporaryPath, path)
91+
if (mode !== undefined) chmodSync(path, mode)
92+
} finally {
93+
rmSync(temporaryPath, { force: true })
94+
}
95+
}
96+
97+
function updateSettingsFile(path: string, settings: Setting[]): Outcome {
98+
let content = existsSync(path) ? readFileSync(path, 'utf8') : '{}\n'
99+
const parsed = parseSettings(content)
100+
if (settings.every(({ path, value }) => readPath(parsed, path) === value)) {
101+
return 'already configured'
102+
}
103+
104+
const options = formattingOptions(content)
105+
for (const setting of settings) {
106+
content = applyEdits(
107+
content,
108+
modify(content, setting.path, setting.value, {
109+
formattingOptions: options
110+
})
111+
)
112+
}
113+
parseSettings(content)
114+
writeSettings(path, content)
115+
return 'updated'
116+
}
117+
118+
function ampSettingsPath(home: string): string {
119+
const directory = join(home, '.config', 'amp')
120+
const jsonPath = join(directory, 'settings.json')
121+
const jsoncPath = join(directory, 'settings.jsonc')
122+
if (existsSync(jsonPath) && existsSync(jsoncPath)) {
123+
throw new Error('both settings.json and settings.jsonc exist')
124+
}
125+
return existsSync(jsoncPath) ? jsoncPath : jsonPath
126+
}
127+
128+
function updateTool(tool: Tool, update: () => Outcome): UpdateResult {
129+
try {
130+
return { tool, outcome: update() }
131+
} catch (error) {
132+
return {
133+
tool,
134+
outcome: 'error',
135+
detail: error instanceof Error ? error.message : 'unknown error'
136+
}
137+
}
138+
}
139+
140+
export function updateAttributionSettings(home: string): UpdateResult[] {
141+
return [
142+
updateTool('claude', () =>
143+
updateSettingsFile(
144+
join(home, '.claude', 'settings.json'),
145+
CLAUDE_SETTINGS
146+
)
147+
),
148+
updateTool('amp', () =>
149+
updateSettingsFile(ampSettingsPath(home), AMP_SETTINGS)
150+
),
151+
{ tool: 'codex', outcome: 'workspace setting required' }
152+
]
153+
}
154+
155+
export function formatResults(results: UpdateResult[]): string {
156+
return results
157+
.map(({ tool, outcome, detail }) =>
158+
detail ? `${tool}: ${outcome} (${detail})` : `${tool}: ${outcome}`
159+
)
160+
.join('\n')
161+
}
162+
163+
function main() {
164+
const { values } = parseArgs({
165+
options: { home: { type: 'string', default: homedir() } }
166+
})
167+
const results = updateAttributionSettings(values.home)
168+
process.stdout.write(`${formatResults(results)}\n`)
169+
if (results.some(({ outcome }) => outcome === 'error')) process.exitCode = 1
170+
}
171+
172+
if (import.meta.url === pathToFileURL(process.argv[1]).href) main()

0 commit comments

Comments
 (0)