agents-bridge 0.3.11 → 0.3.13

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.
@@ -1,3 +1,4 @@
1
+ import { checkWindowsAclBatchProtocol } from './windows-acl-json-smoke.mjs'
1
2
  // Run against the built/installed package on native Windows. No real model,
2
3
  // registry request, production account, or current Bridge configuration is used.
3
4
  import assert from 'node:assert/strict'
@@ -9,7 +10,9 @@ import { tmpdir } from 'node:os'
9
10
  import { join, win32 } from 'node:path'
10
11
  import { once } from 'node:events'
11
12
  import { spawn } from '../dist/agents/platform-spawn.js'
13
+ import { ensureWindowsModelControlSecurity, WINDOWS_ACL_BATCH_PROTOCOL } from '../dist/agents/windows-model-control-security.js'
12
14
  import { createModelControlFileAccess } from '../dist/agents/model-control-files.js'
15
+ import { agentStatePath } from '../dist/config/paths.js'
13
16
  import { automaticModelControl } from '../dist/agents/model-auto-binding.js'
14
17
  import { DefaultCodexAppServerConnection } from '../dist/agents/codex-app-server-runtime.js'
15
18
  import { terminateProcess } from '../dist/agents/process-tree.js'
@@ -23,31 +26,78 @@ if (process.platform !== 'win32') {
23
26
  }
24
27
  const execute = promisify(execFile)
25
28
  const powershell = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe')
26
- const ps = async (script, path) => (await execute(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from("$ErrorActionPreference='Stop';" + script, 'utf16le').toString('base64')], { env: { ...process.env, WMC01_TEST_PATH: path }, windowsHide: true, timeout: 15_000 })).stdout.trim()
27
- const grant = rights => `$p=$env:WMC01_TEST_PATH; $a=Get-Acl -LiteralPath $p; $sid=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'); $r=[Security.AccessControl.FileSystemAccessRule]::new($sid,[Security.AccessControl.FileSystemRights]::${rights},[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow); $a.AddAccessRule($r); Set-Acl -LiteralPath $p -AclObject $a;`
28
- const fixture = async t => {
29
+ // Persist a fresh Access-only object through .NET so fixture preparation also
30
+ // works without SeSecurityPrivilege on an already protected DACL.
31
+ const accessOnlyFixtureScript = `
32
+ $ErrorActionPreference='Stop'
33
+ $ProgressPreference='SilentlyContinue'
34
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
35
+ function Set-TestAccess($path, $acl) {
36
+ $item = Get-Item -LiteralPath $path -Force
37
+ if ($item -is [IO.DirectoryInfo]) { $accessOnly = [Security.AccessControl.DirectorySecurity]::new() }
38
+ else { $accessOnly = [Security.AccessControl.FileSecurity]::new() }
39
+ $section = [Security.AccessControl.AccessControlSections]::Access
40
+ $accessOnly.SetSecurityDescriptorSddlForm($acl.GetSecurityDescriptorSddlForm($section), $section)
41
+ $item.SetAccessControl($accessOnly)
42
+ }
43
+ `
44
+ const ps = async (script, path) => (await execute(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(accessOnlyFixtureScript + script, 'utf16le').toString('base64')], { env: { ...process.env, WMC01_TEST_PATH: path }, windowsHide: true, timeout: 15_000 })).stdout.trim()
45
+ const grant = rights => `$p=$env:WMC01_TEST_PATH; $a=Get-Acl -LiteralPath $p; $sid=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'); $r=[Security.AccessControl.FileSystemAccessRule]::new($sid,[Security.AccessControl.FileSystemRights]::${rights},[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow); $a.AddAccessRule($r); Set-TestAccess $p $a;`
46
+ const fixture = async (t, waitForOperations = async () => {}) => {
29
47
  const root = await mkdtemp(join(await realpath(tmpdir()), 'Bridge Win (中文) '))
30
- t.after(() => rm(root, { recursive: true, force: true }))
48
+ t.after(async () => { await waitForOperations(); await rm(root, { recursive: true, force: true }) })
49
+ // Establish a private DACL on this newly created fixture only. Host TEMP
50
+ // directories may grant Modify to another local account or domain group.
51
+ await ps(`$p=$env:WMC01_TEST_PATH; $a=[Security.AccessControl.DirectorySecurity]::new(); $a.SetAccessRuleProtection($true,$false); $current=[Security.Principal.WindowsIdentity]::GetCurrent().User; foreach($sid in @($current.Value,'S-1-5-18','S-1-5-32-544')) { $identity=[Security.Principal.SecurityIdentifier]::new($sid); $r=[Security.AccessControl.FileSystemAccessRule]::new($identity,[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow); [void]$a.AddAccessRule($r) }; Set-TestAccess $p $a`, root)
31
52
  return root
32
53
  }
33
54
  const logger = { info() {}, warn() {}, debug() {}, error() {} }
55
+ // Direct TAP comments flush while a test is running; t.diagnostic may buffer.
56
+ const progress = text => process.stdout.write(`# WMC01_SMOKE ${text}\n`)
34
57
 
35
58
  test('native ACL: Users read directory, private controls, 12 Agent secret reuse, unsafe writes refused', { timeout: 300_000 }, async t => {
36
- const root = await fixture(t)
59
+ const operations = [], started = Date.now()
60
+ const root = await fixture(t, () => Promise.allSettled(operations))
37
61
  await ps(grant('ReadAndExecute'), root)
38
62
  const directoryAcl = await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', root)
39
63
  const ids = Array.from({ length: 12 }, (_, i) => 'test-agent-' + i)
40
64
  const config = { agents: { defaults: { model: { primary: 'claude-cli/sonnet' } }, list: ids.map((id, i) => ({ id, default: i === 0 })) }, channels: { botlink: { accounts: Object.fromEntries(ids.map(id => [id, { gateway_url: 'https://example.invalid', bot_token: 'dummy-' + id }])) } }, bindings: ids.map(id => ({ agentId: id, match: { channel: 'botlink', accountId: id } })) }
41
65
  const before = JSON.stringify(config), secrets = new Set()
42
66
  const files = createModelControlFileAccess()
43
- for (const id of ids) {
44
- const path = join(root, id, 'state.json')
45
- const first = await automaticModelControl(config, id, path, logger)
46
- const second = await automaticModelControl(config, id, path, logger)
47
- assert.ok(first); assert.equal(first.deviceSecret, second.deviceSecret); secrets.add(first.deviceSecret)
48
- await files.writeSecureModelControlFile(path, 'state-one')
49
- await files.writeSecureModelControlFile(path, 'state-two')
50
- assert.equal(await files.readSecureModelControlFile(path), 'state-two')
67
+ // Independent paths only: at most three real ACL/IO lifecycles at once.
68
+ // allSettled plus the fixture cleanup hook drain active work on failure or
69
+ // cancellation instead of allowing timed-out background tests to keep going.
70
+ for (let offset = 0; offset < ids.length; offset += 3) {
71
+ t.signal.throwIfAborted()
72
+ progress(`ACL batch ${offset / 3 + 1}/4 starting; elapsed_ms=${Date.now() - started}`)
73
+ const batch = ids.slice(offset, offset + 3).map(async id => {
74
+ t.signal.throwIfAborted()
75
+ const path = join(root, id, 'state.json')
76
+ const first = await automaticModelControl(config, id, path, logger)
77
+ t.signal.throwIfAborted()
78
+ assert.ok(first)
79
+ if (id === ids[0]) {
80
+ // Windows PowerShell can write UTF-16; decoding must retain exactly
81
+ // the same device identity and leave the existing bytes untouched.
82
+ await writeFile(path + '.device', Buffer.from('\ufeff' + first.deviceSecret + '\r\n', 'utf16le'))
83
+ }
84
+ const bytesBeforeRestart = await readFile(path + '.device')
85
+ const second = await automaticModelControl(config, id, path, logger)
86
+ t.signal.throwIfAborted()
87
+ assert.ok(second); assert.equal(first.deviceSecret, second.deviceSecret); secrets.add(first.deviceSecret)
88
+ assert.deepEqual(await readFile(path + '.device'), bytesBeforeRestart)
89
+ if (id === ids[0]) {
90
+ await files.writeSecureModelControlFile(path, 'state-one')
91
+ t.signal.throwIfAborted()
92
+ await files.writeSecureModelControlFile(path, 'state-two')
93
+ t.signal.throwIfAborted()
94
+ assert.equal(await files.readSecureModelControlFile(path), 'state-two')
95
+ }
96
+ progress(`ACL completed ${id}; elapsed_ms=${Date.now() - started}`)
97
+ })
98
+ operations.push(...batch)
99
+ const results = await Promise.allSettled(batch)
100
+ for (const result of results) if (result.status === 'rejected') throw result.reason
51
101
  }
52
102
  assert.equal(secrets.size, 12); assert.equal(JSON.stringify(config), before)
53
103
  assert.equal(await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', root), directoryAcl)
@@ -96,3 +146,82 @@ test('native attachment handoff does not call Unix chown, and owned descendant p
96
146
  const closed = once(child, 'close'); await terminateProcess(child.pid); await closed
97
147
  assert.throws(() => process.kill(descendant, 0), { code: 'ESRCH' })
98
148
  })
149
+
150
+
151
+ test('native inherited Modify is repaired only on owned Agent state directory, preserving secrets, parent ACL and denies', { timeout: 180_000 }, async t => {
152
+ const root = await fixture(t), previous = process.env.AGENT_BRIDGE_HOME
153
+ t.after(() => { if (previous === undefined) delete process.env.AGENT_BRIDGE_HOME; else process.env.AGENT_BRIDGE_HOME = previous })
154
+ process.env.AGENT_BRIDGE_HOME = join(root, 'bridge-home')
155
+ await mkdir(process.env.AGENT_BRIDGE_HOME)
156
+ await ps(grant('Modify'), process.env.AGENT_BRIDGE_HOME)
157
+ await ps("$p=$env:WMC01_TEST_PATH; $a=Get-Acl -LiteralPath $p; $sid=[Security.Principal.SecurityIdentifier]::new('S-1-5-21-111-222-333-1003'); $r=[Security.AccessControl.FileSystemAccessRule]::new($sid,[Security.AccessControl.FileSystemRights]::Read,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Deny); [void]$a.AddAccessRule($r); Set-TestAccess $p $a;", process.env.AGENT_BRIDGE_HOME)
158
+ const parentAcl = await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', process.env.AGENT_BRIDGE_HOME)
159
+ const directory = agentStatePath('finopdev'); await mkdir(directory, { recursive: true })
160
+ const path = join(directory, 'cli-sessions.json.model-control.json.device'), session = join(directory, 'cli-sessions.json')
161
+ await writeFile(path, 'original-device-secret'); await writeFile(session, 'existing-session')
162
+ // Retain an explicit deny for a foreign SID when converting inherited ACEs.
163
+ await ps("$p=$env:WMC01_TEST_PATH; $a=Get-Acl -LiteralPath $p; $s=[Security.Principal.SecurityIdentifier]::new('S-1-5-21-111-222-333-1002'); $r=[Security.AccessControl.FileSystemAccessRule]::new($s,[Security.AccessControl.FileSystemRights]::Read,[Security.AccessControl.AccessControlType]::Deny); [void]$a.AddAccessRule($r); Set-TestAccess $p $a;", directory)
164
+ const beforeRepair = JSON.parse(await ps("$a=Get-Acl -LiteralPath $env:WMC01_TEST_PATH; @{inheritedModify=@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|Where-Object{$_.IsInherited -and $_.IdentityReference.Value -eq 'S-1-5-32-545' -and $_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and ([long]$_.FileSystemRights -band 2) -ne 0}).Count; inheritedDenies=@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|Where-Object{$_.IsInherited -and $_.IdentityReference.Value -eq 'S-1-5-21-111-222-333-1003' -and $_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Deny}).Count}|ConvertTo-Json -Compress", directory))
165
+ assert.ok(beforeRepair.inheritedModify > 0, 'fixture must contain inherited Users Modify before repair')
166
+ assert.ok(beforeRepair.inheritedDenies > 0, 'fixture must contain inherited Deny before repair')
167
+ const files = createModelControlFileAccess()
168
+ assert.equal(await files.readSecureModelControlFile(path), 'original-device-secret')
169
+ await files.writeSecureModelControlFile(path, 'same-identity')
170
+ assert.equal(await files.readSecureModelControlFile(path), 'same-identity')
171
+ assert.equal(await readFile(session, 'utf8'), 'existing-session')
172
+ assert.equal(await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', process.env.AGENT_BRIDGE_HOME), parentAcl)
173
+ const snapshot = JSON.parse(await ps("$a=Get-Acl -LiteralPath $env:WMC01_TEST_PATH; @{protected=$a.AreAccessRulesProtected; rules=@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|ForEach-Object{@{sid=$_.IdentityReference.Value;allow=($_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow); inherited=$_.IsInherited}}); current=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value}|ConvertTo-Json -Depth 4 -Compress", directory))
174
+ assert.equal(snapshot.protected, true)
175
+ assert.ok(snapshot.rules.some(r => !r.allow && r.sid === 'S-1-5-21-111-222-333-1002'))
176
+ assert.ok(snapshot.rules.some(r => !r.allow && !r.inherited && r.sid === 'S-1-5-21-111-222-333-1003'))
177
+ assert.ok(snapshot.rules.every(r => !r.inherited), 'replacement DACL must contain only explicit rules')
178
+ assert.ok(snapshot.rules.filter(r => r.allow).every(r => [snapshot.current, 'S-1-5-18', 'S-1-5-32-544'].includes(r.sid)))
179
+ const privateAcl = await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', directory)
180
+ await files.ensureSecureModelControlDirectory(directory)
181
+ assert.equal(await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', directory), privateAcl)
182
+ // Exercise file privatization too: remove an explicit foreign reader while
183
+ // retaining both inherited and explicit Deny masks and owner/group metadata.
184
+ await ps("$p=$env:WMC01_TEST_PATH; $a=Get-Acl -LiteralPath $p; $s=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'); $r=[Security.AccessControl.FileSystemAccessRule]::new($s,[Security.AccessControl.FileSystemRights]::Read,[Security.AccessControl.AccessControlType]::Allow); [void]$a.AddAccessRule($r); $s=[Security.Principal.SecurityIdentifier]::new('S-1-5-21-111-222-333-1004'); $r=[Security.AccessControl.FileSystemAccessRule]::new($s,[Security.AccessControl.FileSystemRights]::Read,[Security.AccessControl.AccessControlType]::Deny); [void]$a.AddAccessRule($r); Set-TestAccess $p $a;", path)
185
+ const fileAcl = async () => JSON.parse(await ps("$a=Get-Acl -LiteralPath $env:WMC01_TEST_PATH; @{owner=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value;group=$a.GetGroup([Security.Principal.SecurityIdentifier]).Value;protected=$a.AreAccessRulesProtected;denies=@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|Where-Object{$_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Deny}|ForEach-Object{($_.IdentityReference.Value + ':' + [long]$_.FileSystemRights + ':' + [int]$_.InheritanceFlags + ':' + [int]$_.PropagationFlags)}|Sort-Object);foreignAllows=@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])|Where-Object{$_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and $_.IdentityReference.Value -notin @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')}).Count}|ConvertTo-Json -Depth 4 -Compress", path))
186
+ const fileBefore = await fileAcl(); assert.ok(fileBefore.foreignAllows > 0)
187
+ assert.equal(await files.readSecureModelControlFile(path), 'same-identity')
188
+ const fileAfter = await fileAcl()
189
+ assert.equal(fileAfter.foreignAllows, 0); assert.equal(fileAfter.protected, true)
190
+ assert.equal(fileAfter.owner, fileBefore.owner); assert.equal(fileAfter.group, fileBefore.group)
191
+ assert.deepEqual(fileAfter.denies, fileBefore.denies)
192
+ const workspace = join(root, 'workspace'); await mkdir(workspace); await ps(grant('Modify'), workspace)
193
+ const workspaceAcl = await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', workspace)
194
+ await assert.rejects(files.createSecureModelControlFile(join(workspace, 'control'), 'never-written'), /directory: unsafe allow/)
195
+ assert.equal(await ps('(Get-Acl -LiteralPath $env:WMC01_TEST_PATH).Sddl', workspace), workspaceAcl)
196
+ })
197
+
198
+
199
+ test('native batched ACL isolates rejected target and preserves healthy peers', { timeout: 90_000 }, async t => {
200
+ const root = await fixture(t)
201
+ const checks = await Promise.allSettled([
202
+ ensureWindowsModelControlSecurity(root, 'directory'),
203
+ ensureWindowsModelControlSecurity(root, 'file'),
204
+ ensureWindowsModelControlSecurity(root, 'directory'),
205
+ ])
206
+ assert.equal(checks[0].status, 'fulfilled')
207
+ assert.equal(checks[2].status, 'fulfilled')
208
+ assert.equal(checks[1].status, 'rejected')
209
+ assert.match(checks[1].reason.message, /MODEL_CONTROL_UNTRUSTED_OWNER/)
210
+ await ensureWindowsModelControlSecurity(root, 'directory')
211
+ progress('batched ACL failure isolation and subsequent check completed')
212
+ })
213
+
214
+
215
+ test('native Windows PowerShell5.1 production batch protocol enumerates1/2/3requests with scalar indexes', { timeout: 60_000 }, async () => {
216
+ const nested = WINDOWS_ACL_BATCH_PROTOCOL.replace(
217
+ '$requests = ConvertFrom-Json -InputObject $env:AGENTS_BRIDGE_ACL_REQUESTS',
218
+ '$requests = @(ConvertFrom-Json -InputObject $env:AGENTS_BRIDGE_ACL_REQUESTS)',
219
+ )
220
+ assert.notEqual(nested, WINDOWS_ACL_BATCH_PROTOCOL)
221
+ // Execute the prior broken protocol too: only an actual shape assertion
222
+ // failure is accepted, never a missing host/process or script error.
223
+ await assert.rejects(checkWindowsAclBatchProtocol(nested), { code: 'ERR_ASSERTION' })
224
+ const result = await checkWindowsAclBatchProtocol()
225
+ assert.deepEqual(result.requestCounts, [1, 2, 3])
226
+ progress('Windows PowerShell5.1 production batch JSON shapes passed1/2/3requests')
227
+ })