Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,27 @@ function recordWrite(policy: FeatureLoopPolicy, absolute: string): void {
if (!policy.writtenPaths.includes(rel)) policy.writtenPaths.push(rel)
}

/**
* Why a file write is refused once the run has shipped, or `undefined` when it is fine.
*
* The ship machine commits the moment the phase is entered, yet the phase lasts
* one more model step. A live run used it: the model wrote a new test file
* AFTER the commit, the file was wrong, and the working tree ended red while the
* committed branch was green. A write at that point can be neither shipped nor
* verified, so it only ever produces a dirty tree that nobody checked.
*
* @param policy - the run's policies.
* @param toolName - the tool about to run.
* @returns the refusal, or `undefined`.
*/
export function writeAfterShipDenial(policy: FeatureLoopPolicy, toolName: string): string | undefined {
if (toolName !== 'write' && toolName !== 'edit') return undefined
const state = policy.pipeline?.run.state
if (state !== 'ship' && state !== 'done') return undefined
return `The run has already shipped (${state}): its work is committed and the checks have passed. `
+ 'A file written now is not in that commit and was never verified. Do not change files; report what was built.'
}

/**
* Remember a write a supervised run ASKED for, so ship can stage it.
*
Expand Down Expand Up @@ -2575,6 +2596,11 @@ export function apply(
// needs the parsed arguments: the command line, the target path. A gate
// that only sees a tool name cannot tell `git push origin fl/x` from
// `git push origin main`, and that distinction is the entire boundary.
const afterShip = writeAfterShipDenial(policy, toolName)
if (afterShip !== undefined) {
state.recordGate(recordAgentMeta(state, agent), toolName, 'deny', afterShip)
return { kind: 'deny', reason: afterShip }
}
const gate = gateEnforce(policy, toolName, rawArgs, stopArmed(policy), sessionCwdOf(agent) ?? process.cwd())
if (gate.kind === 'proceed') {
// Recorded BEFORE dispatch, because the record is evidence the call was
Expand Down
18 changes: 18 additions & 0 deletions test/ask-friction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,21 @@ test('a pipeline that reached done is goal-met, not blocked by the guard that cl
policy.pipeline.run.state = 'blocked'
assert.equal(outcomeOf('blocked', policy), 'blocked', 'a genuinely blocked pipeline still is')
})

test('a file write after ship is refused: it can be neither committed nor verified', async () => {
const { writeAfterShipDenial } = await import('../src/plugin.ts')
const policy = createPolicy({ spec: SPEC, gateMode: 'ask', pipeline: { enabled: true } })
assert.ok(policy.pipeline !== undefined)
for (const state of ['research', 'prd', 'implement', 'test'] as const) {
policy.pipeline.run.state = state
assert.equal(writeAfterShipDenial(policy, 'write'), undefined, state)
}
for (const state of ['ship', 'done'] as const) {
policy.pipeline.run.state = state
assert.match(writeAfterShipDenial(policy, 'write') ?? '', /already shipped/)
assert.match(writeAfterShipDenial(policy, 'edit') ?? '', /already shipped/)
assert.equal(writeAfterShipDenial(policy, 'bash'), undefined, 'reads and shell are not this rule')
assert.equal(writeAfterShipDenial(policy, 'read'), undefined)
}
assert.equal(writeAfterShipDenial(createPolicy({ spec: SPEC, gateMode: 'ask' }), 'write'), undefined, 'no pipeline, no rule')
})
Loading